mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
Merge branch 'master' into feature/docs-walkthrough
This commit is contained in:
commit
4e6aac905d
235 files changed
+14487
-30404
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
/packages/ui/ @supabase/design
|
||||
/packages/shared-data/pricing.ts @roryw10 @supabase/billing
|
||||
/packages/shared-data/plans.ts @roryw10 @supabase/billing
|
||||
/packages/common/telemetry-constants.ts @4L3k51 @supabase/growth-eng
|
||||
/packages/common/telemetry-constants.ts @supabase/growth-eng
|
||||
|
||||
/apps/studio/ @supabase/Dashboard
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-docs:
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
StylesPath = './vale/styles'
|
||||
MinAlertLevel = 'suggestion'
|
||||
|
||||
Vocab = Supabase
|
||||
|
||||
[formats]
|
||||
mdx = md
|
||||
|
||||
[*]
|
||||
BasedOnStyles = Vale, Custom
|
||||
BlockIgnores = (?s) *(<StepHikeCompact>.*?</StepHikeCompact>), \
|
||||
(?s) *(export\s+const\s+\S+\s*=\s*\[.*?\]), \
|
||||
(?s) *(<Image.*?/>)
|
||||
TokenIgnores = (href\s*([=:])\s*['"].*?\1), \
|
||||
+8
-8
@@ -65,7 +65,7 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
|
||||
|
||||
### Install dependencies
|
||||
|
||||
1. Install the dependencies in the root of the repo.
|
||||
1. Install the dependencies in the root of the repo:
|
||||
|
||||
```sh
|
||||
pnpm install # install dependencies
|
||||
@@ -77,7 +77,7 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
|
||||
cp apps/www/.env.local.example apps/www/.env.local
|
||||
```
|
||||
|
||||
3. After that you can run the apps simultaneously with the following.
|
||||
3. After that you can run the apps simultaneously with the following:
|
||||
```sh
|
||||
pnpm dev # start all the applications
|
||||
```
|
||||
@@ -117,8 +117,8 @@ Installing a package in a specific workspace requires you to move to the workspa
|
||||
|
||||
For example:
|
||||
|
||||
1. `cd apps/studio`: move to the `studio` workspace
|
||||
2. `pnpm add react`: installs `react` into `studio` workspace
|
||||
1. `cd apps/studio`: move to the `studio` workspace.
|
||||
2. `pnpm add react`: installs `react` into `studio` workspace.
|
||||
|
||||
|
||||
---
|
||||
@@ -145,7 +145,7 @@ First, make sure you have the Docker installed on your device. You can download
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Run docker
|
||||
3. Run docker:
|
||||
|
||||
```sh
|
||||
docker compose up
|
||||
@@ -186,9 +186,9 @@ Create a new entry in the [`redirects.js`](https://github.com/supabase/supabase/
|
||||
We support "federating" docs, meaning doc content can come directly from external repos other than [`supabase/supabase`](https://github.com/supabase/supabase).
|
||||
|
||||
- It's great for things like client libs who have their own set of docs that we don't want to duplicate on the official Supabase docs (eg. [`supabase/vecs`](https://github.com/supabase/vecs)).
|
||||
- No duplication or manual steps required - fetches and generates automatically as part of the docs build pipeline
|
||||
- It's flexible - you can "embed" external docs nearly anywhere at any level in Supabase docs, but they will feel native
|
||||
- If you are maintaining a repo containing docs that you think could also live in Supabase docs, feel free to create an issue and we can work together to integrate
|
||||
- No duplication or manual steps required - fetches and generates automatically as part of the docs build pipeline.
|
||||
- It's flexible - you can "embed" external docs nearly anywhere at any level in Supabase docs, but they will feel native.
|
||||
- If you are maintaining a repo containing docs that you think could also live in Supabase docs, feel free to create an issue and we can work together to integrate.
|
||||
|
||||
Federated docs work using Next.js's build pipeline. We use `getStaticProps()` to fetch remote documentation (ie. markdown) at build time which is processed and passed to the respective page within the docs.
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
|
||||
- [Bangla / বাংলা](/i18n/README.bn.md)
|
||||
- [Bulgarian / Български](/i18n/README.bg.md)
|
||||
- [Catalan / Català](/i18n/README.ca.md)
|
||||
- [Croatian / Hrvatski](/i18n/README.hr.md)
|
||||
- [Czech / čeština](/i18n/README.cs.md)
|
||||
- [Danish / Dansk](/i18n/README.da.md)
|
||||
- [Dutch / Nederlands](/i18n/README.nl.md)
|
||||
|
||||
@@ -1443,7 +1443,7 @@ export const functions: NavMenuConstant = {
|
||||
url: undefined,
|
||||
items: [
|
||||
{ name: 'Background Tasks', url: '/guides/functions/background-tasks' },
|
||||
{ name: 'Ephemeral Storage', url: '/guides/functions/ephemeral-storage' },
|
||||
{ name: 'File Storage', url: '/guides/functions/ephemeral-storage' },
|
||||
{ name: 'WebSockets', url: '/guides/functions/websockets' },
|
||||
{ name: 'Custom Routing', url: '/guides/functions/routing' },
|
||||
{ name: 'Wasm Modules', url: '/guides/functions/wasm' },
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
## Project setup
|
||||
|
||||
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase and then creating a "schema" inside the database.
|
||||
Before you start building you need to set up the Database and API. You can do this by starting a new Project in Supabase and then creating a "schema" inside the database.
|
||||
|
||||
### Create a project
|
||||
|
||||
1. [Create a new project](https://supabase.com/dashboard) in the Supabase Dashboard.
|
||||
1. Enter your project details.
|
||||
1. Wait for the new database to launch.
|
||||
2. Enter your project details.
|
||||
3. Wait for the new database to launch.
|
||||
|
||||
### Set up the database schema
|
||||
|
||||
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor, or you can just copy/paste the SQL from below and run it yourself.
|
||||
Now set up the database schema. You can use the "User Management Starter" quickstart in the SQL Editor, or you can copy/paste the SQL from below and run it.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
@@ -29,7 +29,7 @@ Now we are going to set up the database schema. We can use the "User Management
|
||||
|
||||
You can pull the database schema down to your local project by running the `db pull` command. Read the [local development docs](/docs/guides/cli/local-development#link-your-project) for detailed instructions.
|
||||
|
||||
</Admonition>
|
||||
{/* TODO: discuss */}
|
||||
|
||||
```bash
|
||||
supabase link --project-ref <project-id>
|
||||
@@ -37,6 +37,8 @@ supabase link --project-ref <project-id>
|
||||
supabase db pull
|
||||
```
|
||||
|
||||
</Admonition>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
@@ -58,7 +60,8 @@ supabase migration new user_management_starter
|
||||
### Get the API keys
|
||||
|
||||
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
|
||||
We just need to get the Project URL and `anon` key from the API settings.
|
||||
|
||||
To do this, you need to get the Project URL and `anon` key from the API settings.
|
||||
|
||||
1. Go to the [API Settings](https://supabase.com/dashboard/project/_/settings/api) page in the Dashboard.
|
||||
1. Find your Project `URL`, `anon`, and `service_role` keys on this page.
|
||||
2. Find your Project `URL`, `anon`, and `service_role` keys on this page.
|
||||
@@ -269,6 +269,7 @@ using ( true );
|
||||
<Admonition type="note" label="Use restrictive policies">
|
||||
|
||||
RLS policies are permissive by default, which means that they are combined using an "OR" operator when multiple policies are applied. It is important to construct restrictive policies to ensure that the checks for an anonymous user are always enforced when combined with other policies.
|
||||
Be aware that a single 'restrictive' RLS policy alone will fail unless combined with another policy that returns true, ensuring the combined condition is met.
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -629,7 +629,7 @@ The PKCE flow allows for server-side authentication. Unlike the implicit flow, w
|
||||
|
||||
Update your reset password email template to send the token hash. See [Email Templates](/docs/guides/auth/auth-email-templates) for how to configure your email templates.
|
||||
|
||||
Your signup email template should contain the following HTML:
|
||||
Your reset password email template should contain the following HTML:
|
||||
|
||||
```html
|
||||
<h2>Reset Password</h2>
|
||||
@@ -960,11 +960,11 @@ Consider configuring a custom SMTP server for production.
|
||||
|
||||
See the [Custom SMTP guide](/docs/guides/auth/auth-smtp) for instructions.
|
||||
|
||||
#### Local development with Inbucket
|
||||
#### Local development with Mailpit
|
||||
|
||||
You can test email flows on your local machine. The Supabase CLI automatically captures emails sent locally by using [Inbucket](https://github.com/inbucket/inbucket).
|
||||
You can test email flows on your local machine. The Supabase CLI automatically captures emails sent locally by using [Mailpit](https://github.com/axllent/mailpit).
|
||||
|
||||
In your terminal, run `supabase status` to get the Inbucket URL. Go to this URL in your browser, and follow the instructions to find your emails.
|
||||
In your terminal, run `supabase status` to get the Mailpit URL. Go to this URL in your browser, and follow the instructions to find your emails.
|
||||
|
||||
## With phone
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ hideToc: true
|
||||
|
||||
|
||||
```ts name=lib/supabase.ts
|
||||
import { AppState } from 'react-native'
|
||||
import { AppState, Platform } from 'react-native'
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { createClient, processLock } from '@supabase/supabase-js'
|
||||
@@ -81,7 +81,7 @@ hideToc: true
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
...(Platform.OS !== "web" ? { storage: AsyncStorage } : {}),
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
@@ -94,13 +94,15 @@ hideToc: true
|
||||
// to receive `onAuthStateChange` events with the `TOKEN_REFRESHED` or
|
||||
// `SIGNED_OUT` event if the user's session is terminated. This should
|
||||
// only be registered once.
|
||||
AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
supabase.auth.startAutoRefresh()
|
||||
} else {
|
||||
supabase.auth.stopAutoRefresh()
|
||||
}
|
||||
})
|
||||
if (Platform.OS !== "web") {
|
||||
AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
supabase.auth.startAutoRefresh()
|
||||
} else {
|
||||
supabase.auth.stopAutoRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
@@ -67,7 +67,7 @@ To access Supabase from your Next.js app, you need 2 types of Supabase clients:
|
||||
1. **Client Component client** - To access Supabase from Client Components, which run in the browser.
|
||||
1. **Server Component client** - To access Supabase from Server Components, Server Actions, and Route Handlers, which run only on the server.
|
||||
|
||||
Create a `utils/supabase` folder with a file for each type of client. Then copy the utility functions for each client type.
|
||||
Create a `utils/supabase` folder at the root of your project, or inside the `./src` folder if you are using one, with a file for each type of client. Then copy the utility functions for each client type.
|
||||
|
||||
<Accordion
|
||||
type="default"
|
||||
|
||||
@@ -29,7 +29,7 @@ Under the hood, Supabase Cron uses the [`pg_cron`](https://github.com/citusdata/
|
||||
|
||||
The extension creates a `cron` schema in your database and all Jobs are stored on the `cron.job` table. Every Job's run and its status is recorded on the `cron.job_run_details` table.
|
||||
|
||||
The Supabase Dashboard provides and interface for you to schedule Jobs and monitor Job runs. You can also do the same with SQL.
|
||||
The Supabase Dashboard provides an interface for you to schedule Jobs and monitor Job runs. You can also do the same with SQL.
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
@@ -1,21 +1,52 @@
|
||||
---
|
||||
id: 'function-ephemeral-storage'
|
||||
title: 'Ephemeral Storage'
|
||||
description: 'Read and write from temporary directory'
|
||||
subtitle: 'Read and write from temporary directory'
|
||||
title: 'File Storage'
|
||||
description: 'Use persistent and ephemeral file storage'
|
||||
subtitle: 'Use persistent and ephemeral file storage'
|
||||
---
|
||||
|
||||
Edge Functions provides ephemeral file storage. You can read and write files to the `/tmp` directory.
|
||||
Edge Functions provides two flavors of file storage:
|
||||
|
||||
This allows you to:
|
||||
- Persistent - backed by S3 protocol, can read/write from any S3 compatible bucket, including Supabase Storage
|
||||
- Ephemeral - You can read and write files to the `/tmp` directory. Only suitable for temporary operations
|
||||
|
||||
You can use file storage to:
|
||||
|
||||
- Process uploaded files temporarily without permanent storage
|
||||
- Handle complex file transformations and workflows
|
||||
- Do data migrations between projects
|
||||
- Process user uploaded files and store them
|
||||
- Unzip archives and process contents before saving to database
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## Persistent Storage
|
||||
|
||||
The persistent storage option is built on top of the S3 protocol. It allows you to mount any S3-compatible bucket, including Supabase Storage Buckets, as a directory for your Edge Functions.
|
||||
You can perform operations such as reading and writing files to the mounted buckets as you would in a POSIX file system.
|
||||
|
||||
To access an S3 bucket from Edge Functions, you must set the following for environment variables in Edge Function Secrets.
|
||||
|
||||
- `S3FS_ENDPOINT_URL`
|
||||
- `S3FS_REGION`
|
||||
- `S3FS_ACCESS_KEY_ID`
|
||||
- `S3FS_SECRET_ACCESS_KEY`
|
||||
|
||||
[Follow this guide](https://supabase.com/docs/guides/storage/s3/authentication) to enable and create an access key for Supabase Storage S3.
|
||||
|
||||
To access a file path in your mounted bucket from your Edge Function, use the prefix `/s3/YOUR-BUCKET-NAME`.
|
||||
|
||||
```tsx
|
||||
// read from S3 bucket
|
||||
const data = await Deno.readFile('/s3/my-bucket/results.csv')
|
||||
|
||||
// make a directory
|
||||
await Deno.mkdir('/s3/my-bucket/sub-dir')
|
||||
|
||||
// write to S3 bucket
|
||||
await Deno.writeTextFile('/s3/my-bucket/demo.txt', 'hello world')
|
||||
```
|
||||
|
||||
## Ephemeral storage
|
||||
|
||||
Ephemeral storage will reset on each function invocation. This means the files you write during an invocation can only be read within the same invocation.
|
||||
|
||||
@@ -128,30 +159,41 @@ Deno.serve(async (req) => {
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
## Using synchronous file APIs
|
||||
|
||||
Currently, the synchronous APIs for creating or writing files are not supported:
|
||||
You can safely use the following synchronous Deno APIs (and their Node counterparts) _during initial script evaluation_:
|
||||
|
||||
- Deno.statSync
|
||||
- Deno.removeSync
|
||||
- Deno.writeFileSync
|
||||
- Deno.writeTextFileSync
|
||||
- Deno.readFileSync
|
||||
- Deno.readTextFileSync
|
||||
- Deno.mkdirSync
|
||||
- Deno.makeTempDirSync
|
||||
- Deno.readDirSync
|
||||
|
||||
**Keep in mind** that the sync APIs are available only during initial script evaluation and aren’t supported in callbacks like HTTP handlers or `setTimeout`.
|
||||
|
||||
```tsx
|
||||
// ❌ Not supported
|
||||
Deno.writeFileSync('/tmp/file.txt', data)
|
||||
Deno.mkdirSync('/tmp/directory')
|
||||
Deno.statSync('...') // ✅
|
||||
|
||||
// ✅ Supported
|
||||
await Deno.writeFile('/tmp/file.txt', data)
|
||||
await Deno.mkdir('/tmp/directory')
|
||||
```
|
||||
setTimeout(() => {
|
||||
Deno.statSync('...') // 💣 ERROR! Deno.statSync is blocklisted on the current context
|
||||
})
|
||||
|
||||
You can use sync variations of read APIs:
|
||||
|
||||
```tsx
|
||||
// ✅ Supported
|
||||
const data = Deno.readFileSync('/tmp/file.txt')
|
||||
Deno.serve(() => {
|
||||
Deno.statSync('...') // 💣 ERROR! Deno.statSync is blocklisted on the current context
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limits
|
||||
|
||||
There are no limits on S3 buckets you mount for Persistent storage.
|
||||
|
||||
Ephemeral Storage:
|
||||
|
||||
- Free projects: Up to 256MB of ephemeral storage
|
||||
- Paid projects: Up to 512MB of ephemeral storage
|
||||
@@ -17,27 +17,27 @@ If you get stuck while working through this guide, refer to the [full example on
|
||||
|
||||
## Building the app
|
||||
|
||||
Let's start building the Angular app from scratch.
|
||||
Start with building the Angular app from scratch.
|
||||
|
||||
### Initialize an Angular app
|
||||
|
||||
We can use the [Angular CLI](https://angular.io/cli) to initialize
|
||||
an app called `supabase-angular`:
|
||||
You can use the [Angular CLI](https://angular.io/cli) to initialize
|
||||
an app called `supabase-angular`. The command sets some defaults, that you change to suit your needs:
|
||||
|
||||
```bash
|
||||
npx ng new supabase-angular --routing false --style css --standalone false
|
||||
npx ng new supabase-angular --routing false --style css --standalone false --zoneless true --ssr false
|
||||
cd supabase-angular
|
||||
```
|
||||
|
||||
Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
Then, install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
And finally we want to save the environment variables in the `src/environments/environment.ts` file.
|
||||
All we need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
These variables will be exposed on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
||||
Finally, save the environment variables in the `src/environments/environment.ts` file.
|
||||
All you need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
The application exposes these variables in the browser, and that's fine as you have [Row Level Security](/docs/guides/auth#row-level-security) enabled on the Database.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const environment = {
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
Now that we have the API credentials in place, let's create a `SupabaseService` with `ng g s supabase` to initialize the Supabase client and implement functions to communicate with the Supabase API.
|
||||
Now you have the API credentials in place, create a `SupabaseService` with `ng g s supabase` and add the following code to initialize the Supabase client and implement functions to communicate with the Supabase API.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
@@ -133,37 +133,41 @@ export class SupabaseService {
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
Optionally, update [src/styles.css](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/angular-user-management/src/styles.css) to style the app.
|
||||
Optionally, update `src/styles.css` [with the following styles](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/angular-user-management/src/styles.css) to style the app.
|
||||
|
||||
### Set up a login component
|
||||
|
||||
Let's set up an Angular component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
||||
Create an `AuthComponent` with `ng g c auth` Angular CLI command.
|
||||
Next, set up an Angular component to manage logins and sign ups. The component uses [Magic Links](/docs/guides/auth/auth-email-passwordless#with-magic-link), so users can sign in with their email without using passwords.
|
||||
|
||||
Create an `AuthComponent` with the `ng g c auth` Angular CLI command and add the following code.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/auth/auth.component.ts
|
||||
```ts name=src/app/auth/auth.ts
|
||||
import { Component } from '@angular/core'
|
||||
import { FormBuilder } from '@angular/forms'
|
||||
import { FormBuilder, FormGroup } from '@angular/forms'
|
||||
import { SupabaseService } from '../supabase.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth',
|
||||
templateUrl: './auth.component.html',
|
||||
styleUrls: ['./auth.component.css'],
|
||||
templateUrl: './auth.html',
|
||||
styleUrls: ['./auth.css'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AuthComponent {
|
||||
loading = false
|
||||
|
||||
signInForm = this.formBuilder.group({
|
||||
email: '',
|
||||
})
|
||||
|
||||
signInForm!: FormGroup
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
private readonly formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
loading = false
|
||||
ngOnInit() {
|
||||
this.signInForm = this.formBuilder.group({
|
||||
email: '',
|
||||
})
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
try {
|
||||
this.loading = true
|
||||
@@ -183,7 +187,7 @@ export class AuthComponent {
|
||||
}
|
||||
```
|
||||
|
||||
```html name=src/app/auth/auth.component.html
|
||||
```html name=src/app/auth/auth.html
|
||||
<div class="row flex-center flex">
|
||||
<div class="col-6 form-widget" aria-live="polite">
|
||||
<h1 class="header">Supabase + Angular</h1>
|
||||
@@ -201,7 +205,7 @@ export class AuthComponent {
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="button block" [disabled]="loading">
|
||||
{{ loading ? 'Loading' : 'Send magic link' }}
|
||||
{{ loading ? "Loading" : "Send magic link" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -214,38 +218,50 @@ export class AuthComponent {
|
||||
### Account page
|
||||
|
||||
Users also need a way to edit their profile details and manage their accounts after signing in.
|
||||
Create an `AccountComponent` with the `ng g c account` Angular CLI command.
|
||||
Create an `AccountComponent` with the `ng g c account` Angular CLI command and add the following code.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/account/account.component.ts
|
||||
```ts name=src/app/account/account.ts
|
||||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { FormBuilder } from '@angular/forms'
|
||||
import { FormBuilder, FormGroup } from '@angular/forms'
|
||||
import { AuthSession } from '@supabase/supabase-js'
|
||||
import { Profile, SupabaseService } from '../supabase.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
templateUrl: './account.component.html',
|
||||
styleUrls: ['./account.component.css'],
|
||||
templateUrl: './account.html',
|
||||
styleUrls: ['./account.css'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AccountComponent implements OnInit {
|
||||
loading = false
|
||||
profile!: Profile
|
||||
updateProfileForm!: FormGroup
|
||||
|
||||
get avatarUrl() {
|
||||
return this.updateProfileForm.value.avatar_url as string
|
||||
}
|
||||
async updateAvatar(event: string): Promise<void> {
|
||||
this.updateProfileForm.patchValue({
|
||||
avatar_url: event,
|
||||
})
|
||||
await this.updateProfile()
|
||||
}
|
||||
|
||||
@Input()
|
||||
session!: AuthSession
|
||||
|
||||
updateProfileForm = this.formBuilder.group({
|
||||
username: '',
|
||||
website: '',
|
||||
avatar_url: '',
|
||||
})
|
||||
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
private formBuilder: FormBuilder
|
||||
) {}
|
||||
) {
|
||||
this.updateProfileForm = this.formBuilder.group({
|
||||
username: '',
|
||||
website: '',
|
||||
avatar_url: '',
|
||||
})
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.getProfile()
|
||||
@@ -311,8 +327,9 @@ export class AccountComponent implements OnInit {
|
||||
}
|
||||
```
|
||||
|
||||
```html name=src/app/account/account.component.html
|
||||
```html name=src/app/account/account.html
|
||||
<form [formGroup]="updateProfileForm" (ngSubmit)="updateProfile()" class="form-widget">
|
||||
<app-avatar [avatarUrl]="this.avatarUrl" (upload)="updateAvatar($event)"> </app-avatar>
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="text" [value]="session.user.email" disabled />
|
||||
@@ -328,7 +345,7 @@ export class AccountComponent implements OnInit {
|
||||
|
||||
<div>
|
||||
<button type="submit" class="button primary block" [disabled]="loading">
|
||||
{{ loading ? 'Loading ...' : 'Update' }}
|
||||
{{ loading ? "Loading ..." : "Update" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -342,33 +359,34 @@ export class AccountComponent implements OnInit {
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update `AppComponent`:
|
||||
Now you have all the components in place, update `AppComponent`:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/app.component.ts
|
||||
```ts name=src/app/app.ts
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { SupabaseService } from './supabase.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
templateUrl: './app.html',
|
||||
styleUrls: ['./app.css'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
title = 'angular-user-management'
|
||||
|
||||
session = this.supabase.session
|
||||
|
||||
constructor(private readonly supabase: SupabaseService) {}
|
||||
|
||||
title = 'angular-user-management'
|
||||
session: any
|
||||
|
||||
ngOnInit() {
|
||||
this.session = this.supabase.session
|
||||
this.supabase.authChanges((_, session) => (this.session = session))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html name=src/app/app.component.html
|
||||
```html name=src/app/app.html
|
||||
<div class="container" style="padding: 50px 0 100px 0">
|
||||
<app-account *ngIf="session; else auth" [session]="session"></app-account>
|
||||
<ng-template #auth>
|
||||
@@ -379,7 +397,7 @@ export class AppComponent implements OnInit {
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
`app.module.ts` also needs to be modified to include the `ReactiveFormsModule` from the `@angular/forms` package.
|
||||
You also need to change `app.module.ts` to include the `ReactiveFormsModule` from the `@angular/forms` package.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
@@ -387,32 +405,33 @@ export class AppComponent implements OnInit {
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
|
||||
import { AppComponent } from './app.component'
|
||||
import { AuthComponent } from './auth/auth.component'
|
||||
import { AccountComponent } from './account/account.component'
|
||||
import { AppComponent } from './app'
|
||||
import { AuthComponent } from './auth/auth'
|
||||
import { AccountComponent } from './account/account'
|
||||
import { ReactiveFormsModule } from '@angular/forms'
|
||||
import { AvatarComponent } from './avatar/avatar.component'
|
||||
import { AvatarComponent } from './avatar/avatar'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent, AuthComponent, AccountComponent, AvatarComponent],
|
||||
imports: [BrowserModule, ReactiveFormsModule],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent],
|
||||
exports: [AppComponent, AuthComponent, AccountComponent, AvatarComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
Once that's done, run this in a terminal window:
|
||||
Once that's done, run the application in a terminal:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
And then open the browser to [localhost:4200](http://localhost:4200) and you should see the completed app.
|
||||
Open the browser to [localhost:4200](http://localhost:4200) and you should see the completed app.
|
||||
|
||||

|
||||

|
||||
|
||||
## Bonus: Profile photos
|
||||
|
||||
@@ -420,20 +439,21 @@ Every Supabase project is configured with [Storage](/docs/guides/storage) for ma
|
||||
|
||||
### Create an upload widget
|
||||
|
||||
Let's create an avatar for the user so that they can upload a profile photo.
|
||||
Create an `AvatarComponent` with `ng g c avatar` Angular CLI command.
|
||||
Create an avatar for the user so that they can upload a profile photo.
|
||||
Create an `AvatarComponent` with `ng g c avatar` Angular CLI command and add the following code.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/avatar/avatar.component.ts
|
||||
```ts name=src/app/avatar/avatar.ts
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { SafeResourceUrl, DomSanitizer } from '@angular/platform-browser'
|
||||
import { SupabaseService } from '../supabase.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-avatar',
|
||||
templateUrl: './avatar.component.html',
|
||||
styleUrls: ['./avatar.component.css'],
|
||||
templateUrl: './avatar.html',
|
||||
styleUrls: ['./avatar.css'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AvatarComponent {
|
||||
_avatarUrl: SafeResourceUrl | undefined
|
||||
@@ -490,7 +510,7 @@ export class AvatarComponent {
|
||||
}
|
||||
```
|
||||
|
||||
```html name=src/app/avatar/avatar.component.html
|
||||
```html name=src/app/avatar/avatar.html
|
||||
<div>
|
||||
<img
|
||||
*ngIf="_avatarUrl"
|
||||
@@ -503,10 +523,10 @@ export class AvatarComponent {
|
||||
<div *ngIf="!_avatarUrl" class="avatar no-image" style="height: 150px; width: 150px"></div>
|
||||
<div style="width: 150px">
|
||||
<label class="button primary block" for="single">
|
||||
{{ uploading ? 'Uploading ...' : 'Upload' }}
|
||||
{{ uploading ? "Uploading ..." : "Upload" }}
|
||||
</label>
|
||||
<input
|
||||
style="visibility: hidden;position: absolute"
|
||||
style="visibility: hidden; position: absolute"
|
||||
type="file"
|
||||
id="single"
|
||||
accept="image/*"
|
||||
@@ -524,9 +544,9 @@ And then we can add the widget on top of the `AccountComponent` HTML template:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```html name=src/app/account.component.html
|
||||
```html name=src/app/account.html
|
||||
<form [formGroup]="updateProfileForm" (ngSubmit)="updateProfile()" class="form-widget">
|
||||
<app-avatar [avatarUrl]="this.avatarUrl" (upload)="updateAvatar($event)"> </app-avatar>
|
||||
<app-avatar [avatarUrl]="this.avatarUrl" (upload)="updateAvatar($event)"></app-avatar>
|
||||
<!-- input fields -->
|
||||
</form>
|
||||
```
|
||||
@@ -537,11 +557,11 @@ And add an `updateAvatar` function along with an `avatarUrl` getter to the `Acco
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/account.component.ts
|
||||
```ts name=src/app/account.ts
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
templateUrl: './account.component.html',
|
||||
styleUrls: ['./account.component.css'],
|
||||
templateUrl: './account.html',
|
||||
styleUrls: ['./account.css'],
|
||||
})
|
||||
export class AccountComponent implements OnInit {
|
||||
// ...
|
||||
|
||||
@@ -5,6 +5,17 @@ subtitle: Learn how to develop locally and use the Supabase CLI
|
||||
|
||||
Develop locally while running the Supabase stack on your machine.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
As a prerequisite, you must install a container runtime compatible with Docker APIs.
|
||||
|
||||
- [Docker Desktop](https://docs.docker.com/desktop/) (macOS, Windows, Linux)
|
||||
- [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux)
|
||||
- [Podman](https://podman.io/) (macOS, Windows, Linux)
|
||||
- [OrbStack](https://orbstack.dev/) (macOS)
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. Install the Supabase CLI:
|
||||
@@ -101,17 +112,6 @@ Develop locally while running the Supabase stack on your machine.
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
As a prerequisite, you must install a container runtime compatible with Docker APIs.
|
||||
|
||||
- [Docker Desktop](https://docs.docker.com/desktop/) (macOS, Windows, Linux)
|
||||
- [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux)
|
||||
- [Podman](https://podman.io/) (macOS, Windows, Linux)
|
||||
- [OrbStack](https://orbstack.dev/) (macOS)
|
||||
|
||||
</Admonition>
|
||||
|
||||
4. View your local Supabase instance at [http://localhost:54323](http://localhost:54323).
|
||||
|
||||
## Local development
|
||||
|
||||
@@ -217,7 +217,7 @@ Started supabase local development setup.
|
||||
API URL: http://localhost:54321
|
||||
DB URL: postgresql://postgres:postgres@localhost:54322/postgres
|
||||
Studio URL: http://localhost:54323
|
||||
Inbucket URL: http://localhost:54324
|
||||
Mailpit URL: http://localhost:54324
|
||||
anon key: eyJh......
|
||||
service_role key: eyJh......
|
||||
|
||||
|
||||
@@ -36,11 +36,11 @@ Edge Functions are powered by Deno, which provides a [native set of testing tool
|
||||
|
||||
## Testing Auth emails
|
||||
|
||||
The Supabase CLI uses [Inbucket](https://github.com/inbucket/inbucket) to capture emails sent from your local machine. This is useful for testing emails sent from Supabase Auth.
|
||||
The Supabase CLI uses [Mailpit](https://github.com/axllent/mailpit) to capture emails sent from your local machine. This is useful for testing emails sent from Supabase Auth.
|
||||
|
||||
### Accessing Inbucket
|
||||
### Accessing Mailpit
|
||||
|
||||
By default, Inbucket is available at [localhost:54324](http://localhost:54324) when you run `supabase start`. Open this URL in your browser to view the emails.
|
||||
By default, Mailpit is available at [localhost:54324](http://localhost:54324) when you run `supabase start`. Open this URL in your browser to view the emails.
|
||||
|
||||
### Going into production
|
||||
|
||||
|
||||
@@ -260,20 +260,13 @@ The table below shows the actions each role can take on the resources belonging
|
||||
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | Run | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| Events Collections | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | Update | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | Delete | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| Warehouse Access Tokens | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | Revoke | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| <a href="#branching-permissions" id="branching-permissions">**Branching**</a> | | | | | |
|
||||
| Enable branching | - | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| Disable branching | - | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| Production Branch | Read | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | Write | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| Development Branches | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| | Update | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| | Delete | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
|
||||
[^1]: Available on the Team and Enterprise Plans.
|
||||
[^2]: Sending anonymous data to OpenAI is opt in and can improve Studio AI Assistant's responses.
|
||||
|
||||
@@ -27,7 +27,7 @@ As an example, if you start a Pro Plan subscription on January 1 and downgrade t
|
||||
|
||||
## Credit top-ups
|
||||
|
||||
You can top up credits at any time, with a maximum of <Price price="999" /> per top-up. These credits do not expire and are non-refundable.
|
||||
You can top up credits at any time, with a maximum of <Price price="2000" /> per top-up. These credits do not expire and are non-refundable.
|
||||
|
||||
If you have any outstanding invoices, we’ll automatically use your credits to pay them off. Any remaining credits will be applied to future invoices.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: 'firestore-data'
|
||||
title: 'Migrated from Firebase Firestore to Supabase'
|
||||
title: 'Migrate from Firebase Firestore to Supabase'
|
||||
description: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
|
||||
subtitle: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
|
||||
---
|
||||
|
||||
@@ -227,7 +227,7 @@ supabase db diff --linked --schema auth,storage > changes.sql
|
||||
The new project has the old project's Storage buckets, but the Storage objects need to be migrated manually. Use this script to move storage objects from one project to another.
|
||||
|
||||
```js
|
||||
// npm install @supabase/supabase-js@1
|
||||
// npm install @supabase/supabase-js@2
|
||||
const { createClient } = require('@supabase/supabase-js')
|
||||
|
||||
const OLD_PROJECT_URL = 'https://xxx.supabase.co'
|
||||
@@ -238,7 +238,9 @@ const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy'
|
||||
|
||||
;(async () => {
|
||||
const oldSupabaseRestClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY, {
|
||||
schema: 'storage',
|
||||
db: {
|
||||
schema: 'storage',
|
||||
},
|
||||
})
|
||||
const oldSupabaseClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY)
|
||||
const newSupabaseClient = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY)
|
||||
|
||||
@@ -207,7 +207,7 @@ select * from pgmq.send_batch(
|
||||
|
||||
#### `read`
|
||||
|
||||
Read 1 or more messages from a queue. The VT specifies the delay in seconds between reading and the message becoming invisible to other consumers.
|
||||
Read 1 or more messages from a queue. The VT specifies the duration of time in seconds that the message is invisible to other consumers. At the end of that duration, the message is visible again and could be read by other consumers.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```sql
|
||||
|
||||
@@ -25,6 +25,14 @@ This is all the information you need to connect to Supabase Storage using any S3
|
||||
|
||||
<img alt="Storage S3 Access keys" src="/docs/img/storage/s3-credentials.png" width="100%" />
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
For optimal performance when uploading large files you should always use the direct storage hostname. This provides several performance enhancements that will greatly improve performance when uploading large files.
|
||||
|
||||
Instead of `https://project-id.supabase.co` use `https://project-id.storage.supabase.co`
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
@@ -39,7 +47,7 @@ This is all the information you need to connect to Supabase Storage using any S3
|
||||
const client = new S3Client({
|
||||
forcePathStyle: true,
|
||||
region: 'project_region',
|
||||
endpoint: 'https://project_ref.supabase.co/storage/v1/s3',
|
||||
endpoint: 'https://project_ref.storage.supabase.co/storage/v1/s3',
|
||||
credentials: {
|
||||
accessKeyId: 'your_access_key_id',
|
||||
secretAccessKey: 'your_secret_access_key',
|
||||
@@ -55,7 +63,7 @@ This is all the information you need to connect to Supabase Storage using any S3
|
||||
[supabase]
|
||||
aws_access_key_id = your_access_key_id
|
||||
aws_secret_access_key = your_secret_access_key
|
||||
endpoint_url = https://project_ref.supabase.co/storage/v1/s3
|
||||
endpoint_url = https://project_ref.storage.supabase.co/storage/v1/s3
|
||||
region = project_region
|
||||
```
|
||||
</TabPanel>
|
||||
@@ -86,7 +94,7 @@ const {
|
||||
const client = new S3Client({
|
||||
forcePathStyle: true,
|
||||
region: 'project_region',
|
||||
endpoint: 'https://project_ref.supabase.co/storage/v1/s3',
|
||||
endpoint: 'https://project_ref.storage.supabase.co/storage/v1/s3',
|
||||
credentials: {
|
||||
accessKeyId: 'project_ref',
|
||||
secretAccessKey: 'anonKey',
|
||||
|
||||
@@ -8,13 +8,13 @@ sidebar_label: 'Limits'
|
||||
|
||||
## Global file size
|
||||
|
||||
You can set the max file size across all your buckets by setting this global value in the dashboard [here](https://supabase.com/dashboard/project/_/settings/storage). For Free projects, the limit can't exceed 50 MB. On the Pro Plan and up, you can set this value to up to 50 GB. If you need more than 50 GB, [contact us](https://supabase.com/dashboard/support/new).
|
||||
You can set the max file size across all your buckets by setting this global value in the dashboard [here](https://supabase.com/dashboard/project/_/settings/storage). For Free projects, the limit can't exceed 50 MB. On the Pro Plan and up, you can set this value to up to 500 GB. If you need more than 500 GB, [contact us](https://supabase.com/dashboard/support/new).
|
||||
|
||||
| Plan | Max File Size Limit |
|
||||
| ---------- | ------------------- |
|
||||
| Free | 50 MB |
|
||||
| Pro | 50 GB |
|
||||
| Team | 50 GB |
|
||||
| Pro | 500 GB |
|
||||
| Team | 500 GB |
|
||||
| Enterprise | Custom |
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
@@ -14,6 +14,14 @@ The resumable upload method is recommended when:
|
||||
|
||||
Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resumable uploads. TUS stands for The Upload Server and is an open protocol for supporting resumable uploads. The protocol allows the upload process to be resumed from where it left off in case of interruptions. This method can be implemented using the [`tus-js-client`](https://github.com/tus/tus-js-client) library, or other client-side libraries like [Uppy](https://uppy.io/docs/tus/) that support the TUS protocol.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
For optimal performance when uploading large files you should always use the direct storage hostname. This provides several performance enhancements that will greatly improve performance when uploading large files.
|
||||
|
||||
Instead of `https://project-id.supabase.co` use `https://project-id.storage.supabase.co`
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
@@ -35,7 +43,8 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
var upload = new tus.Upload(file, {
|
||||
endpoint: `https://${projectId}.supabase.co/storage/v1/upload/resumable`,
|
||||
// Supabase TUS endpoint (with direct storage hostname)
|
||||
endpoint: `https://${projectId}.storage.supabase.co/storage/v1/upload/resumable`,
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
authorization: `Bearer ${session.access_token}`,
|
||||
@@ -125,7 +134,7 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
|
||||
// Initialize Uppy instance only once
|
||||
const [uppy] = useState(() => new Uppy());
|
||||
// Initialize Supabase client with project URL and anon key
|
||||
const supabase = createClient(projectURL, anonKey);
|
||||
const supabase = createClient(`https://${projectId}.supabase.co`, anonKey);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeUppy = async () => {
|
||||
@@ -135,7 +144,8 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
uppy.use(Tus, {
|
||||
endpoint: `${projectURL}/storage/v1/upload/resumable`, // Supabase TUS endpoint
|
||||
// Supabase TUS endpoint (with direct storage hostname)
|
||||
endpoint: `https://${projectId}.storage.supabase.co/storage/v1/upload/resumable`,
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000], // Retry delays for resumable uploads
|
||||
headers: {
|
||||
authorization: `Bearer ${session?.access_token}`, // User session access token
|
||||
|
||||
@@ -17,7 +17,7 @@ The S3 protocol supports file upload using:
|
||||
|
||||
The `PutObject` action uploads the file in a single request. This matches the behavior of the Supabase SDK [Standard Upload](/docs/guides/storage/uploads/standard-uploads).
|
||||
|
||||
Use `PutObject` to upload smaller files, where retrying the entire upload won't be an issue. The maximum file size on paid plans is 50 GB.
|
||||
Use `PutObject` to upload smaller files, where retrying the entire upload won't be an issue. The maximum file size on paid plans is 500 GB.
|
||||
|
||||
For example, using JavaScript and the `aws-sdk` client:
|
||||
|
||||
@@ -42,7 +42,7 @@ await s3Client.send(uploadCommand)
|
||||
|
||||
Multipart Uploads split the file into smaller parts and upload them in parallel, maximizing the upload speed on a fast network. When uploading large files, this allows you to retry the upload of individual parts in case of network issues.
|
||||
|
||||
This method is preferable over [Resumable Upload](/docs/guides/storage/uploads/resumable-uploads) for server-side uploads, when you want to maximize upload speed at the cost of resumability. The maximum file size on paid plans is 50 GB.
|
||||
This method is preferable over [Resumable Upload](/docs/guides/storage/uploads/resumable-uploads) for server-side uploads, when you want to maximize upload speed at the cost of resumability. The maximum file size on paid plans is 500 GB.
|
||||
|
||||
### Upload a file in parts
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"build:llms": "tsx ./scripts/llms.ts",
|
||||
"build:sitemap": "tsx ./internals/generate-sitemap.ts",
|
||||
"clean": "rimraf .next .turbo node_modules features/docs/generated examples __generated__",
|
||||
"codegen:examples": "cp -r ../../examples ./examples",
|
||||
"codegen:examples": "shx cp -r ../../examples ./examples",
|
||||
"codegen:graphql": "tsx --conditions=react-server ./scripts/graphqlSchema.ts && graphql-codegen --config codegen.ts",
|
||||
"codegen:references": "tsx features/docs/Reference.generated.script.ts",
|
||||
"codemod:frontmatter": "node ./scripts/codemod/mdx-meta.mjs && prettier --cache --write \"content/**/*.mdx\"",
|
||||
@@ -149,6 +149,7 @@
|
||||
"openapi-types": "^12.1.3",
|
||||
"postcss": "^8.5.3",
|
||||
"shiki": "^3.2.1",
|
||||
"shx": "^0.4.0",
|
||||
"simple-git": "^3.24.0",
|
||||
"slugify": "^1.6.6",
|
||||
"smol-toml": "^1.3.1",
|
||||
|
||||
@@ -26,7 +26,9 @@ Chris Caruso
|
||||
Chris Chandler
|
||||
Chris Copplestone
|
||||
Chris Gwilliams
|
||||
Chris Martin
|
||||
Chris Stockton
|
||||
Chris Ward
|
||||
Craig Cannon
|
||||
Danny White
|
||||
Darren Cunningham
|
||||
@@ -39,6 +41,7 @@ Divit D
|
||||
Eduardo Gurgel
|
||||
Egor Romanov
|
||||
Eleftheria Trivyzaki
|
||||
Emmett Folger
|
||||
Etienne Stalmans
|
||||
Fabrizio Fenoglio
|
||||
Felipe Stival
|
||||
@@ -54,6 +57,7 @@ Inian P
|
||||
Ivan Vasilov
|
||||
Jenny Kibiri
|
||||
Jess Shears
|
||||
Jim Chanco Jr
|
||||
John Pena
|
||||
Jon M
|
||||
Jonny Summers-Muir
|
||||
@@ -81,6 +85,7 @@ Manan Gupta
|
||||
Margarita Sandomirskaia
|
||||
Mark Burggraf
|
||||
Monica Khoury
|
||||
Mykhailo Mischa Lieibenson
|
||||
Nyannyacha
|
||||
Oli R
|
||||
Pamela Chia
|
||||
@@ -104,6 +109,7 @@ Stanislav M
|
||||
Stephen Morgan
|
||||
Steve Chavez
|
||||
Stojan Dimitrovski
|
||||
Sugu Sougoumarane
|
||||
Supun Sudaraka Kalidasa
|
||||
Taryn King
|
||||
Terry Sutton
|
||||
|
||||
+2897
-960
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,48 @@
|
||||
"type": "markdown",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Advisors",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-get-performance-advisors",
|
||||
"title": "Get performance advisors",
|
||||
"slug": "v1-get-performance-advisors",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-security-advisors",
|
||||
"title": "Get security advisors",
|
||||
"slug": "v1-get-security-advisors",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Analytics",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-get-project-logs",
|
||||
"title": "Get project logs",
|
||||
"slug": "v1-get-project-logs",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-usage-api-count",
|
||||
"title": "Get project usage api count",
|
||||
"slug": "v1-get-project-usage-api-count",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-usage-request-count",
|
||||
"title": "Get project usage request count",
|
||||
"slug": "v1-get-project-usage-request-count",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Auth",
|
||||
@@ -16,12 +58,36 @@
|
||||
"slug": "v1-create-a-sso-provider",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-legacy-signing-key",
|
||||
"title": "Create legacy signing key",
|
||||
"slug": "v1-create-legacy-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-project-signing-key",
|
||||
"title": "Create project signing key",
|
||||
"slug": "v1-create-project-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-project-tpa-integration",
|
||||
"title": "Create project tpa integration",
|
||||
"slug": "v1-create-project-tpa-integration",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-a-sso-provider",
|
||||
"title": "Delete a sso provider",
|
||||
"slug": "v1-delete-a-sso-provider",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-project-tpa-integration",
|
||||
"title": "Delete project tpa integration",
|
||||
"slug": "v1-delete-project-tpa-integration",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-a-sso-provider",
|
||||
"title": "Get a sso provider",
|
||||
@@ -34,12 +100,48 @@
|
||||
"slug": "v1-get-auth-service-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-legacy-signing-key",
|
||||
"title": "Get legacy signing key",
|
||||
"slug": "v1-get-legacy-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-signing-key",
|
||||
"title": "Get project signing key",
|
||||
"slug": "v1-get-project-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-signing-keys",
|
||||
"title": "Get project signing keys",
|
||||
"slug": "v1-get-project-signing-keys",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-tpa-integration",
|
||||
"title": "Get project tpa integration",
|
||||
"slug": "v1-get-project-tpa-integration",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-sso-provider",
|
||||
"title": "List all sso provider",
|
||||
"slug": "v1-list-all-sso-provider",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-project-tpa-integrations",
|
||||
"title": "List project tpa integrations",
|
||||
"slug": "v1-list-project-tpa-integrations",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-remove-project-signing-key",
|
||||
"title": "Remove project signing key",
|
||||
"slug": "v1-remove-project-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-a-sso-provider",
|
||||
"title": "Update a sso provider",
|
||||
@@ -51,6 +153,36 @@
|
||||
"title": "Update auth service config",
|
||||
"slug": "v1-update-auth-service-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-project-signing-key",
|
||||
"title": "Update project signing key",
|
||||
"slug": "v1-update-project-signing-key",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Billing",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-apply-project-addon",
|
||||
"title": "Apply project addon",
|
||||
"slug": "v1-apply-project-addon",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-project-addons",
|
||||
"title": "List project addons",
|
||||
"slug": "v1-list-project-addons",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-remove-project-addon",
|
||||
"title": "Remove project addon",
|
||||
"slug": "v1-remove-project-addon",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -64,6 +196,12 @@
|
||||
"slug": "v1-apply-a-migration",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-restore-point",
|
||||
"title": "Create restore point",
|
||||
"slug": "v1-create-restore-point",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-disable-readonly-mode-temporarily",
|
||||
"title": "Disable readonly mode temporarily",
|
||||
@@ -88,6 +226,18 @@
|
||||
"slug": "v1-get-a-snippet",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-database-metadata",
|
||||
"title": "Get database metadata",
|
||||
"slug": "v1-get-database-metadata",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-pooler-config",
|
||||
"title": "Get pooler config",
|
||||
"slug": "v1-get-pooler-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-postgres-config",
|
||||
"title": "Get postgres config",
|
||||
@@ -106,6 +256,12 @@
|
||||
"slug": "v1-get-readonly-mode-status",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-restore-point",
|
||||
"title": "Get restore point",
|
||||
"slug": "v1-get-restore-point",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-ssl-enforcement-config",
|
||||
"title": "Get ssl enforcement config",
|
||||
@@ -125,9 +281,9 @@
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-migrations",
|
||||
"title": "List migrations",
|
||||
"slug": "v1-list-migrations",
|
||||
"id": "v1-list-migration-history",
|
||||
"title": "List migration history",
|
||||
"slug": "v1-list-migration-history",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
@@ -154,6 +310,18 @@
|
||||
"slug": "v1-setup-a-read-replica",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-undo",
|
||||
"title": "Undo",
|
||||
"slug": "v1-undo",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-pooler-config",
|
||||
"title": "Update pooler config",
|
||||
"slug": "v1-update-pooler-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-postgres-config",
|
||||
"title": "Update postgres config",
|
||||
@@ -165,6 +333,12 @@
|
||||
"title": "Update ssl enforcement config",
|
||||
"slug": "v1-update-ssl-enforcement-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-upsert-a-migration",
|
||||
"title": "Upsert a migration",
|
||||
"slug": "v1-upsert-a-migration",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -292,12 +466,24 @@
|
||||
"slug": "v1-delete-a-branch",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-diff-a-branch",
|
||||
"title": "Diff a branch",
|
||||
"slug": "v1-diff-a-branch",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-disable-preview-branching",
|
||||
"title": "Disable preview branching",
|
||||
"slug": "v1-disable-preview-branching",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-a-branch",
|
||||
"title": "Get a branch",
|
||||
"slug": "v1-get-a-branch",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-a-branch-config",
|
||||
"title": "Get a branch config",
|
||||
@@ -352,6 +538,12 @@
|
||||
"slug": "v1-exchange-oauth-token",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-oauth-authorize-project-claim",
|
||||
"title": "Oauth authorize project claim",
|
||||
"slug": "v1-oauth-authorize-project-claim",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-revoke-token",
|
||||
"title": "Revoke token",
|
||||
@@ -364,6 +556,12 @@
|
||||
"type": "category",
|
||||
"title": "Organizations",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-claim-project-for-organization",
|
||||
"title": "Claim project for organization",
|
||||
"slug": "v1-claim-project-for-organization",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-an-organization",
|
||||
"title": "Create an organization",
|
||||
@@ -376,6 +574,12 @@
|
||||
"slug": "v1-get-an-organization",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-organization-project-claim",
|
||||
"title": "Get organization project claim",
|
||||
"slug": "v1-get-organization-project-claim",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-organizations",
|
||||
"title": "List all organizations",
|
||||
@@ -406,6 +610,12 @@
|
||||
"slug": "v1-create-a-project",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-project-claim-token",
|
||||
"title": "Create project claim token",
|
||||
"slug": "v1-create-project-claim-token",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-a-project",
|
||||
"title": "Delete a project",
|
||||
@@ -418,6 +628,12 @@
|
||||
"slug": "v1-delete-network-bans",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-project-claim-token",
|
||||
"title": "Delete project claim token",
|
||||
"slug": "v1-delete-project-claim-token",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-network-restrictions",
|
||||
"title": "Get network restrictions",
|
||||
@@ -442,6 +658,12 @@
|
||||
"slug": "v1-get-project",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-claim-token",
|
||||
"title": "Get project claim token",
|
||||
"slug": "v1-get-project-claim-token",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-services-health",
|
||||
"title": "Get services health",
|
||||
@@ -454,6 +676,12 @@
|
||||
"slug": "v1-list-all-network-bans",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-network-bans-enriched",
|
||||
"title": "List all network bans enriched",
|
||||
"slug": "v1-list-all-network-bans-enriched",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-projects",
|
||||
"title": "List all projects",
|
||||
@@ -526,18 +754,42 @@
|
||||
"slug": "v1-bulk-delete-secrets",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-project-api-key",
|
||||
"title": "Create project api key",
|
||||
"slug": "v1-create-project-api-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-project-api-key",
|
||||
"title": "Delete project api key",
|
||||
"slug": "v1-delete-project-api-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-pgsodium-config",
|
||||
"title": "Get pgsodium config",
|
||||
"slug": "v1-get-pgsodium-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-api-key",
|
||||
"title": "Get project api key",
|
||||
"slug": "v1-get-project-api-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-api-keys",
|
||||
"title": "Get project api keys",
|
||||
"slug": "v1-get-project-api-keys",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-legacy-api-keys",
|
||||
"title": "Get project legacy api keys",
|
||||
"slug": "v1-get-project-legacy-api-keys",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-secrets",
|
||||
"title": "List all secrets",
|
||||
@@ -549,6 +801,18 @@
|
||||
"title": "Update pgsodium config",
|
||||
"slug": "v1-update-pgsodium-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-project-api-key",
|
||||
"title": "Update project api key",
|
||||
"slug": "v1-update-project-api-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-project-legacy-api-keys",
|
||||
"title": "Update project legacy api keys",
|
||||
"slug": "v1-update-project-legacy-api-keys",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@ import { getConnectionStrings } from 'components/interfaces/Connect/DatabaseSett
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { pluckObjectFields } from 'lib/helpers'
|
||||
import { useState } from 'react'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -23,13 +22,14 @@ import {
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
interface ExportDialogProps {
|
||||
table?: { name: string; schema: string }
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => {
|
||||
export const ExportDialog = ({ table, open, onOpenChange }: ExportDialogProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<string>('csv')
|
||||
|
||||
const { data: databases } = useReadReplicasQuery({ projectRef })
|
||||
@@ -47,14 +47,14 @@ export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => {
|
||||
poolingInfo: { connectionString: '', db_host: '', db_name: '', db_port: 0, db_user: '' },
|
||||
})
|
||||
|
||||
const outputName = `${snap.table.name}_rows`
|
||||
const outputName = `${table?.name}_rows`
|
||||
|
||||
const csvExportCommand = `
|
||||
${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${snap.table.schema}"."${snap.table.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv
|
||||
${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${table?.schema}"."${table?.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv
|
||||
`.trim()
|
||||
|
||||
const sqlExportCommand = `
|
||||
pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${snap.table.schema}.${snap.table.name}" --data-only --column-inserts > ${outputName}.sql
|
||||
pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${table?.schema}.${table?.name}" --data-only --column-inserts > ${outputName}.sql
|
||||
`.trim()
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
|
||||
import { fetchAllTableRows, useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { RoleImpersonationState } from 'lib/role-impersonation'
|
||||
import {
|
||||
useRoleImpersonationStateSnapshot,
|
||||
@@ -80,10 +80,12 @@ export default Header
|
||||
|
||||
const DefaultHeader = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const org = useSelectedOrganization()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const onAddRow =
|
||||
@@ -277,7 +279,7 @@ const RowHeader = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function onRowsExportCSV() {
|
||||
const onRowsExportCSV = async () => {
|
||||
setIsExporting(true)
|
||||
|
||||
if (snap.allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
|
||||
@@ -349,7 +351,7 @@ const RowHeader = () => {
|
||||
columns: snap.table!.columns.map((column) => column.name),
|
||||
})
|
||||
const csvData = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
toast.success(`Downloaded ${rows.length} rows to CSV`, {
|
||||
toast.success(`Downloaded ${rows.length} rows as CSV`, {
|
||||
id: toastId,
|
||||
closeButton: true,
|
||||
duration: 4000,
|
||||
@@ -358,7 +360,7 @@ const RowHeader = () => {
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
async function onRowsExportSQL() {
|
||||
const onRowsExportSQL = async () => {
|
||||
setIsExporting(true)
|
||||
|
||||
if (snap.allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
|
||||
@@ -423,7 +425,7 @@ const RowHeader = () => {
|
||||
|
||||
const sqlStatements = formatTableRowsToSQL(snap.table, rows)
|
||||
const sqlData = new Blob([sqlStatements], { type: 'text/sql;charset=utf-8;' })
|
||||
toast.success(`Downloading ${rows.length} rows to SQL`, {
|
||||
toast.success(`Downloading ${rows.length} rows as SQL`, {
|
||||
id: toastId,
|
||||
closeButton: true,
|
||||
duration: 4000,
|
||||
@@ -432,6 +434,27 @@ const RowHeader = () => {
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
const onRowsExportJSON = async () => {
|
||||
if (!project) {
|
||||
return toast.error('Project is required')
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
const toastId = toast.loading(
|
||||
`Exporting ${snap.selectedRows.size} row${snap.selectedRows.size > 1 ? 's' : ''} from ${snap.table.name}`
|
||||
)
|
||||
const rows = allRows.filter((x) => snap.selectedRows.has(x.idx))
|
||||
const sqlData = new Blob([JSON.stringify(rows)], { type: 'text/sql;charset=utf-8;' })
|
||||
toast.success(`Downloading ${rows.length} rows as JSON`, {
|
||||
id: toastId,
|
||||
closeButton: true,
|
||||
duration: 4000,
|
||||
})
|
||||
saveAs(sqlData, `${snap.table!.name}_rows.json`)
|
||||
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
function deselectRows() {
|
||||
snap.setSelectedRows(new Set())
|
||||
}
|
||||
@@ -486,13 +509,15 @@ const RowHeader = () => {
|
||||
<DropdownMenuItem onClick={onRowsExportSQL}>Export as SQL</DropdownMenuItem>
|
||||
{/* [Joshen] Should make this available for all cases, but that'll involve updating
|
||||
the Dialog's SQL output to be dynamic based on any filters applied */}
|
||||
{snap.allRowsSelected && (
|
||||
{snap.allRowsSelected ? (
|
||||
<DropdownMenuItem className="group" onClick={() => setShowExportModal(true)}>
|
||||
<div>
|
||||
<p className="group-hover:text-foreground">Export via CLI</p>
|
||||
<p className="text-foreground-lighter">Recommended for large tables</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={onRowsExportJSON}>Export as JSON</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -509,7 +534,11 @@ const RowHeader = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExportDialog open={showExportModal} onOpenChange={() => setShowExportModal(false)} />
|
||||
<ExportDialog
|
||||
table={{ name: snap.table.name, schema: snap.table.schema ?? '' }}
|
||||
open={showExportModal}
|
||||
onOpenChange={() => setShowExportModal(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { screen, waitFor } from '@testing-library/dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { render } from 'tests/helpers'
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
import NewAccessTokenButton from './NewAccessTokenButton'
|
||||
|
||||
describe(`NewAccessTokenButton`, () => {
|
||||
beforeEach(() => {
|
||||
addAPIMock({
|
||||
method: `post`,
|
||||
path: `/platform/profile/access-tokens`,
|
||||
response: {
|
||||
name: faker.lorem.word(),
|
||||
scope: faker.helpers.arrayElement(['V0', undefined]),
|
||||
created_at: faker.date.past().toISOString(),
|
||||
id: faker.number.int(),
|
||||
token_alias: faker.lorem.words(),
|
||||
token: faker.lorem.words(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it(`generates regular tokens`, async () => {
|
||||
const onCreateToken = vi.fn()
|
||||
render(<NewAccessTokenButton onCreateToken={onCreateToken} />)
|
||||
|
||||
const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` })
|
||||
await userEvent.click(dialogTrigger)
|
||||
|
||||
const nameInput = screen.getByLabelText(`Name`)
|
||||
await userEvent.type(nameInput, `test{enter}`)
|
||||
|
||||
await waitFor(() => expect(onCreateToken).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it(`generates experimental tokens`, async () => {
|
||||
const onCreateToken = vi.fn()
|
||||
render(<NewAccessTokenButton onCreateToken={onCreateToken} />)
|
||||
|
||||
const dropdownTrigger = screen.getByTitle(`Choose token scope`)
|
||||
await userEvent.click(dropdownTrigger)
|
||||
|
||||
const experimentalMenuItem = await screen.findByRole(`menuitem`, {
|
||||
name: `Generate token for experimental API`,
|
||||
})
|
||||
await userEvent.click(experimentalMenuItem)
|
||||
|
||||
await waitFor(async () => {
|
||||
await expect(
|
||||
screen.findByRole(`heading`, { name: `Generate token for experimental API` })
|
||||
).resolves.toBeInTheDocument()
|
||||
await expect(screen.findByRole(`alert`)).resolves.toBeInTheDocument()
|
||||
})
|
||||
|
||||
const nameInput = screen.getByLabelText(`Name`)
|
||||
await userEvent.type(nameInput, `test{enter}`)
|
||||
|
||||
await waitFor(() => expect(onCreateToken).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it(`resets the form on close/cancel`, async () => {
|
||||
render(<NewAccessTokenButton onCreateToken={vi.fn()} />)
|
||||
|
||||
// pass 1: open dialog and fill form
|
||||
const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` })
|
||||
await userEvent.click(dialogTrigger)
|
||||
|
||||
let nameInput = screen.getByLabelText(`Name`)
|
||||
await userEvent.type(nameInput, `cancel button test`)
|
||||
expect(nameInput).toHaveValue(`cancel button test`)
|
||||
|
||||
// reset the form by pressing the cancel button
|
||||
const cancelButton = screen.getByRole(`button`, { name: `Cancel` })
|
||||
await userEvent.click(cancelButton)
|
||||
|
||||
// pass 2: check that the form is reset, then fill it again
|
||||
await userEvent.click(dialogTrigger)
|
||||
|
||||
nameInput = screen.getByLabelText(`Name`)
|
||||
expect(nameInput).not.toHaveValue(`cancel button test`)
|
||||
|
||||
await userEvent.type(nameInput, `close modal test`)
|
||||
expect(nameInput).toHaveValue(`close modal test`)
|
||||
|
||||
// reset the form by closing the dialog
|
||||
await userEvent.keyboard(`{Escape}`)
|
||||
|
||||
// pass 3: check that the form has been rest again
|
||||
await userEvent.click(dialogTrigger)
|
||||
|
||||
nameInput = screen.getByLabelText(`Name`)
|
||||
expect(nameInput).not.toHaveValue(`close modal test`)
|
||||
})
|
||||
})
|
||||
@@ -1,157 +1,187 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ChevronDown, ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { type SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
export interface NewAccessTokenButtonProps {
|
||||
onCreateToken: (token: any) => void
|
||||
}
|
||||
|
||||
const TokenSchema = z.object({
|
||||
tokenName: z.string().min(1, 'Please enter a name for the token'),
|
||||
})
|
||||
|
||||
const formId = 'new-access-token-form'
|
||||
|
||||
const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [tokenScope, setTokenScope] = useState<'V0' | undefined>(undefined)
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {}
|
||||
if (!values.tokenName) errors.tokenName = 'Please enter a name for the token'
|
||||
return errors
|
||||
}
|
||||
|
||||
const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation({
|
||||
onSuccess: (res) => {
|
||||
onCreateToken(res)
|
||||
setIsOpen(false)
|
||||
},
|
||||
const form = useForm<z.infer<typeof TokenSchema>>({
|
||||
resolver: zodResolver(TokenSchema),
|
||||
defaultValues: { tokenName: '' },
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation()
|
||||
|
||||
const onFormSubmit = async (values: any) => {
|
||||
createAccessToken({ name: values.tokenName, scope: tokenScope })
|
||||
const onSubmit: SubmitHandler<z.infer<typeof TokenSchema>> = async (values) => {
|
||||
createAccessToken(
|
||||
{ name: values.tokenName, scope: tokenScope },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Your access token "${data.name}" is ready.`)
|
||||
form.reset()
|
||||
onCreateToken(data)
|
||||
setVisible(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container max-w-7xl">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="rounded-r-none px-3"
|
||||
onClick={() => {
|
||||
setTokenScope(undefined)
|
||||
setVisible(true)
|
||||
}}
|
||||
>
|
||||
Generate new token
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="rounded-r-none px-3"
|
||||
type="primary"
|
||||
title="Choose token scope"
|
||||
className="rounded-l-none px-[4px] py-[5px]"
|
||||
icon={<ChevronDown />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuItem
|
||||
key="experimental-token"
|
||||
onClick={() => {
|
||||
setTokenScope(undefined)
|
||||
setIsOpen(true)
|
||||
setTokenScope('V0')
|
||||
setVisible(true)
|
||||
}}
|
||||
>
|
||||
Generate new token
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="primary"
|
||||
className="rounded-l-none px-[4px] py-[5px]"
|
||||
icon={<ChevronDown />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuItem
|
||||
key="experimental-token"
|
||||
onClick={() => {
|
||||
setTokenScope('V0')
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="block text-foreground">Generate token for experimental API</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="block text-foreground">Generate token for experimental API</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
hideFooter
|
||||
size="small"
|
||||
visible={isOpen}
|
||||
onCancel={() => setIsOpen(!isOpen)}
|
||||
header={
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h5 className="text-sm text-foreground">
|
||||
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
|
||||
</h5>
|
||||
</div>
|
||||
}
|
||||
<Dialog
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) form.reset()
|
||||
setVisible(open)
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
validateOnBlur
|
||||
initialValues={{ tokenName: '' }}
|
||||
onSubmit={onFormSubmit}
|
||||
validate={validate}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
{tokenScope === 'V0' && (
|
||||
<Modal.Content>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="The experimental API provides additional endpoints which allows you to manage
|
||||
your organizations and projects."
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
These include deleting organizations and projects which cannot be undone.
|
||||
As such, be very careful when using this API.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
href="https://api.supabase.com/api/v0"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Experimental API documentation
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Modal.Content>
|
||||
)}
|
||||
<Modal.Content>
|
||||
<Input
|
||||
id="tokenName"
|
||||
label="Name"
|
||||
placeholder="Provide a name for your token"
|
||||
className="w-full"
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="flex flex-col gap-4">
|
||||
{tokenScope === 'V0' && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
These include deleting organizations and projects which cannot be undone. As
|
||||
such, be very careful when using this API.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
href="https://api.supabase.com/api/v0"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Experimental API documentation
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
key="tokenName"
|
||||
name="tokenName"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="tokenName" label="Name">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="tokenName"
|
||||
{...field}
|
||||
placeholder="Provide a name for your token"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center space-x-2 justify-end">
|
||||
<Button type="default" disabled={isLoading} onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" loading={isLoading} disabled={isLoading}>
|
||||
Generate token
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
form.reset()
|
||||
setVisible(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button form={formId} htmlType="submit" loading={isLoading}>
|
||||
Generate token
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Modal } from 'ui'
|
||||
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
|
||||
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
|
||||
import AddPaymentMethodForm from './AddPaymentMethodForm'
|
||||
import { getStripeElementsAppearanceOptions } from './Payment.utils'
|
||||
|
||||
@@ -18,8 +17,6 @@ interface AddNewPaymentMethodModalProps {
|
||||
returnUrl: string
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
showSetDefaultCheckbox?: boolean
|
||||
autoMarkAsDefaultPaymentMethod?: boolean
|
||||
}
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
||||
@@ -29,14 +26,11 @@ const AddNewPaymentMethodModal = ({
|
||||
returnUrl,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
showSetDefaultCheckbox,
|
||||
autoMarkAsDefaultPaymentMethod,
|
||||
}: AddNewPaymentMethodModalProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [intent, setIntent] = useState<any>()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
const captchaLoaded = useIsHCaptchaLoaded()
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
|
||||
|
||||
@@ -64,7 +58,7 @@ const AddNewPaymentMethodModal = ({
|
||||
}
|
||||
|
||||
const loadPaymentForm = async () => {
|
||||
if (visible && captchaRef && captchaLoaded) {
|
||||
if (visible && captchaRef) {
|
||||
let token = captchaToken
|
||||
|
||||
try {
|
||||
@@ -82,7 +76,7 @@ const AddNewPaymentMethodModal = ({
|
||||
}
|
||||
|
||||
loadPaymentForm()
|
||||
}, [visible, captchaRef, captchaLoaded])
|
||||
}, [visible, captchaRef])
|
||||
|
||||
const resetCaptcha = () => {
|
||||
setCaptchaToken(null)
|
||||
@@ -142,8 +136,6 @@ const AddNewPaymentMethodModal = ({
|
||||
returnUrl={returnUrl}
|
||||
onCancel={onLocalCancel}
|
||||
onConfirm={onLocalConfirm}
|
||||
showSetDefaultCheckbox={showSetDefaultCheckbox}
|
||||
autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod}
|
||||
/>
|
||||
</Elements>
|
||||
</Modal>
|
||||
|
||||
@@ -1,49 +1,56 @@
|
||||
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
NewPaymentMethodElement,
|
||||
type PaymentMethodElementRef,
|
||||
} from 'components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement'
|
||||
import { organizationKeys } from 'data/organizations/keys'
|
||||
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
|
||||
import { useOrganizationPaymentMethodMarkAsDefaultMutation } from 'data/organizations/organization-payment-method-default-mutation'
|
||||
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
|
||||
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
interface AddPaymentMethodFormProps {
|
||||
returnUrl: string
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
showSetDefaultCheckbox?: boolean
|
||||
autoMarkAsDefaultPaymentMethod?: boolean
|
||||
}
|
||||
|
||||
// Stripe docs recommend to use the new SetupIntent flow over
|
||||
// manually creating and attaching payment methods via the API
|
||||
// Small UX annoyance here, that the page will be refreshed
|
||||
|
||||
const AddPaymentMethodForm = ({
|
||||
returnUrl,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
showSetDefaultCheckbox = false,
|
||||
autoMarkAsDefaultPaymentMethod = false,
|
||||
}: AddPaymentMethodFormProps) => {
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps) => {
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
const { data: customerProfile, isLoading: customerProfileLoading } =
|
||||
useOrganizationCustomerProfileQuery({
|
||||
slug: selectedOrganization?.slug,
|
||||
})
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDefault, setIsDefault] = useState(showSetDefaultCheckbox)
|
||||
const [isDefaultPaymentMethod, setIsDefaultPaymentMethod] = useState(true)
|
||||
const [isPrimaryBillingAddress, setIsPrimaryBillingAddress] = useState(true)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation()
|
||||
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
|
||||
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
|
||||
const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({
|
||||
slug: selectedOrganization?.slug,
|
||||
})
|
||||
|
||||
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
|
||||
|
||||
const handleSubmit = async (event: any) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!stripe || !elements) {
|
||||
console.error('Stripe.js has not loaded')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
if (document !== undefined) {
|
||||
@@ -51,25 +58,20 @@ const AddPaymentMethodForm = ({
|
||||
document.body.classList.add('!pointer-events-auto')
|
||||
}
|
||||
|
||||
const { error, setupIntent } = await stripe.confirmSetup({
|
||||
elements,
|
||||
redirect: 'if_required',
|
||||
confirmParams: { return_url: returnUrl },
|
||||
})
|
||||
const result = await paymentRef.current?.confirmSetup()
|
||||
|
||||
if (error) {
|
||||
if (!result) {
|
||||
setIsSaving(false)
|
||||
toast.error(error?.message ?? ' Failed to save card details')
|
||||
} else {
|
||||
if (
|
||||
(isDefault || autoMarkAsDefaultPaymentMethod) &&
|
||||
isDefaultPaymentMethod &&
|
||||
selectedOrganization &&
|
||||
typeof setupIntent?.payment_method === 'string'
|
||||
typeof result.setupIntent?.payment_method === 'string'
|
||||
) {
|
||||
try {
|
||||
await markAsDefault({
|
||||
slug: selectedOrganization.slug,
|
||||
paymentMethodId: setupIntent.payment_method,
|
||||
paymentMethodId: result.setupIntent.payment_method,
|
||||
})
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
@@ -82,10 +84,10 @@ const AddPaymentMethodForm = ({
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
defaultPaymentMethodId: setupIntent.payment_method,
|
||||
defaultPaymentMethodId: result.setupIntent.payment_method,
|
||||
data: prev.data.map((pm: any) => ({
|
||||
...pm,
|
||||
is_default: pm.id === setupIntent.payment_method,
|
||||
is_default: pm.id === result.setupIntent.payment_method,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -101,6 +103,28 @@ const AddPaymentMethodForm = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isPrimaryBillingAddress) {
|
||||
try {
|
||||
if (
|
||||
result.address &&
|
||||
(!isEqual(result.address, customerProfile?.address) ||
|
||||
customerProfile?.billing_name !== result.customerName)
|
||||
) {
|
||||
await updateCustomerProfile({
|
||||
slug: selectedOrganization?.slug,
|
||||
billing_name: result.customerName,
|
||||
address: result.address,
|
||||
})
|
||||
}
|
||||
|
||||
if (result.taxId && !isEqual(result.taxId, taxId)) {
|
||||
await updateTaxId({ taxId: result.taxId, slug: selectedOrganization?.slug })
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update billing address')
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
onConfirm()
|
||||
}
|
||||
@@ -110,33 +134,64 @@ const AddPaymentMethodForm = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (customerProfileLoading || isCustomerTaxIdLoading) {
|
||||
return (
|
||||
<Modal.Content>
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
</div>
|
||||
</Modal.Content>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal.Content
|
||||
className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`}
|
||||
>
|
||||
<PaymentElement
|
||||
className="[.p-LinkAutofillPrompt]:pt-0"
|
||||
options={{
|
||||
defaultValues: { billingDetails: { email: selectedOrganization?.billing_email ?? '' } },
|
||||
}}
|
||||
<NewPaymentMethodElement
|
||||
readOnly={isSaving}
|
||||
email={selectedOrganization?.billing_email}
|
||||
currentAddress={customerProfile?.address}
|
||||
customerName={customerProfile?.billing_name}
|
||||
currentTaxId={taxId}
|
||||
ref={paymentRef}
|
||||
/>
|
||||
{showSetDefaultCheckbox && (
|
||||
<div className="flex items-center gap-x-2 mt-4 mb-2">
|
||||
<Checkbox_Shadcn_
|
||||
id="save-as-default"
|
||||
checked={isDefault}
|
||||
onCheckedChange={(checked) => {
|
||||
if (typeof checked === 'boolean') {
|
||||
setIsDefault(checked)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
|
||||
Save as default payment method
|
||||
</Label_Shadcn_>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-2 mt-4 mb-2">
|
||||
<Checkbox_Shadcn_
|
||||
id="save-as-default"
|
||||
checked={isDefaultPaymentMethod}
|
||||
onCheckedChange={(checked) => {
|
||||
if (typeof checked === 'boolean') {
|
||||
setIsDefaultPaymentMethod(checked)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
|
||||
Save as default payment method
|
||||
</Label_Shadcn_>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2 mt-4 mb-2">
|
||||
<Checkbox_Shadcn_
|
||||
id="is-primary-billing-address"
|
||||
checked={isPrimaryBillingAddress}
|
||||
onCheckedChange={(checked) => {
|
||||
if (typeof checked === 'boolean') {
|
||||
setIsPrimaryBillingAddress(checked)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label_Shadcn_ htmlFor="is-primary-billing-address" className="text-foreground-light">
|
||||
Use the billing address as my organization's primary address
|
||||
</Label_Shadcn_>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="flex items-center space-x-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ export const getStripeElementsAppearanceOptions = (
|
||||
resolvedTheme: string | undefined
|
||||
): Appearance => {
|
||||
return {
|
||||
labels: 'floating',
|
||||
theme: (resolvedTheme?.includes('dark') ? 'night' : 'flat') as 'night' | 'flat',
|
||||
variables: {
|
||||
fontSizeBase: '14px',
|
||||
@@ -12,18 +13,27 @@ export const getStripeElementsAppearanceOptions = (
|
||||
: 'hsl(0deg 0% 95.3%)',
|
||||
fontFamily:
|
||||
'var(--font-custom, Circular, custom-font, Helvetica Neue, Helvetica, Arial, sans-serif)',
|
||||
spacingUnit: '4px',
|
||||
borderRadius: '.375rem',
|
||||
gridRowSpacing: '4px',
|
||||
},
|
||||
rules: {
|
||||
'.Label': {
|
||||
// Hide labels - it is obvious enough what the fields are for
|
||||
fontSize: '0',
|
||||
},
|
||||
'.TermsText': {
|
||||
fontSize: '12px',
|
||||
},
|
||||
'.Label--floating': {
|
||||
fontSize: '14px',
|
||||
},
|
||||
'.Label--resting': {
|
||||
fontSize: '14px',
|
||||
color: 'rgb(137, 137, 137)',
|
||||
},
|
||||
'.Input': {
|
||||
boxShadow: 'none',
|
||||
height: '34px',
|
||||
lineHeight: '16px',
|
||||
padding: '8px 12px',
|
||||
},
|
||||
'.AccordionItem': {
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,16 @@ import {
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Label_Shadcn_ as Label,
|
||||
Switch,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
|
||||
export const CreateBranchModal = () => {
|
||||
const allowDataBranching = useFlag('allowDataBranching')
|
||||
const { ref } = useParams()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -141,6 +144,7 @@ export const CreateBranchModal = () => {
|
||||
(val) => gitlessBranching || !githubConnection || (val && val.length > 0),
|
||||
'Git branch name is required when GitHub is connected'
|
||||
),
|
||||
withData: z.boolean().default(false).optional(),
|
||||
})
|
||||
.superRefine(async (val, ctx) => {
|
||||
if (val.gitBranchName && val.gitBranchName.length > 0 && githubConnection?.repository.id) {
|
||||
@@ -164,7 +168,7 @@ export const CreateBranchModal = () => {
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onBlur',
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: { branchName: '', gitBranchName: '' },
|
||||
defaultValues: { branchName: '', gitBranchName: '', withData: false },
|
||||
})
|
||||
|
||||
const canSubmit = !isCreating && !isChecking
|
||||
@@ -182,7 +186,9 @@ export const CreateBranchModal = () => {
|
||||
createBranch({
|
||||
projectRef,
|
||||
branchName: data.branchName,
|
||||
is_default: false,
|
||||
...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}),
|
||||
...(allowDataBranching ? { withData: data.withData } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,6 +300,7 @@ export const CreateBranchModal = () => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingConnections && <GenericSkeletonLoader />}
|
||||
{isErrorConnections && (
|
||||
<AlertError
|
||||
@@ -314,7 +321,7 @@ export const CreateBranchModal = () => {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light">
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Keep this preview branch in sync with a chosen GitHub branch
|
||||
</p>
|
||||
</div>
|
||||
@@ -325,6 +332,23 @@ export const CreateBranchModal = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{allowDataBranching && (
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="withData"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Include data"
|
||||
layout="flex-row-reverse"
|
||||
description="Clone production data into this branch"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormMessage_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Modal,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
export interface CollectionFormValues {
|
||||
name: string
|
||||
retention_days: number
|
||||
}
|
||||
|
||||
interface CollectionFormProps {
|
||||
initialValues?: Partial<CollectionFormValues>
|
||||
onSubmit: (values: CollectionFormValues) => void
|
||||
isLoading?: boolean
|
||||
onCancelClick: () => void
|
||||
}
|
||||
|
||||
export const CollectionForm = ({
|
||||
initialValues,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
onCancelClick,
|
||||
}: CollectionFormProps) => {
|
||||
const { plan, isLoading: planLoading } = useCurrentOrgPlan()
|
||||
const router = useRouter()
|
||||
const currentOrg = useSelectedOrganization()
|
||||
|
||||
function getMaxRetentionDays() {
|
||||
if (planLoading) return 1
|
||||
if (plan?.id === 'free') return 3
|
||||
else return 90
|
||||
}
|
||||
const maxRetentionDays = getMaxRetentionDays()
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
retention_days: z.coerce.number().min(1).max(maxRetentionDays).multipleOf(1).positive().int(),
|
||||
})
|
||||
|
||||
const form = useForm<CollectionFormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: initialValues?.name || '',
|
||||
retention_days: initialValues?.retention_days || maxRetentionDays,
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(onSubmit)
|
||||
|
||||
const formatDays = (days: number) => {
|
||||
return days === 1 ? `1 day` : `${days} days`
|
||||
}
|
||||
|
||||
const retentionDescription = (days: number) => {
|
||||
if (!Boolean(days) || days < 1)
|
||||
return <>Your plan allows for a maximum of {formatDays(maxRetentionDays)}.</>
|
||||
return (
|
||||
<>
|
||||
Your logs will be removed after {formatDays(days)}. <br />
|
||||
Your plan allows for a maximum of {formatDays(maxRetentionDays)}.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Modal.Content className="space-y-3">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Collection name" layout="horizontal">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ autoFocus placeholder="Events" {...field} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="retention_days"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Retention"
|
||||
layout="horizontal"
|
||||
description={retentionDescription(Number(field.value))}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Input_Shadcn_ type="number" {...field} />
|
||||
<span className="absolute right-3 text-foreground-lighter text-sm pointer-events-none">
|
||||
days
|
||||
</span>
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</Modal.Content>
|
||||
|
||||
{plan?.id === 'free' && (
|
||||
<>
|
||||
<Modal.Separator />
|
||||
<Admonition
|
||||
className="border-none bg-transparent"
|
||||
type="default"
|
||||
title="Upgrade your plan to increase retention"
|
||||
description={
|
||||
<div>
|
||||
Upgrade to the pro plan to increase retention to 90 days.
|
||||
<Button
|
||||
className="mt-2"
|
||||
type="primary"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/org/${currentOrg?.slug}/billing?panel=subscriptionPlan&source=dataWarehouseIncreaseRetention`
|
||||
)
|
||||
}
|
||||
>
|
||||
Upgrade plan
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<FormMessage_Shadcn_ />
|
||||
<Modal.Content className="py-4 border-t flex items-center justify-end gap-2">
|
||||
<Button size="tiny" type="default" onClick={onCancelClick}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="tiny" loading={isLoading} disabled={isLoading} htmlType="submit">
|
||||
Create collection
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useCreateCollection } from 'data/analytics/warehouse-collections-create-mutation'
|
||||
import { Modal } from 'ui'
|
||||
import { CollectionForm } from './CollectionForm'
|
||||
|
||||
export const CreateWarehouseCollectionModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
|
||||
const { mutate: createCollection, isLoading } = useCreateCollection({
|
||||
onSuccess: (data) => {
|
||||
onOpenChange(false)
|
||||
toast.success('Collection created successfully')
|
||||
router.push(`/project/${ref}/logs/collections/${data.token}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="medium"
|
||||
onCancel={() => onOpenChange(false)}
|
||||
header="Create collection"
|
||||
visible={open}
|
||||
hideFooter
|
||||
>
|
||||
<CollectionForm
|
||||
onCancelClick={() => onOpenChange(false)}
|
||||
isLoading={isLoading}
|
||||
onSubmit={(vals) => {
|
||||
if (!ref) {
|
||||
return
|
||||
}
|
||||
createCollection({
|
||||
projectRef: ref,
|
||||
name: vals.name,
|
||||
retention_days: vals.retention_days,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@ui/components/shadcn/ui/select'
|
||||
import CopyButton from 'components/ui/CopyButton'
|
||||
import type { WarehouseAccessTokensData } from 'data/analytics/warehouse-access-tokens-query'
|
||||
import type { WarehouseCollectionsData } from 'data/analytics/warehouse-collections-query'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox_Shadcn_,
|
||||
CodeBlock,
|
||||
Input_Shadcn_,
|
||||
Label_Shadcn_,
|
||||
Separator,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
export function TestCollectionDialog({
|
||||
accessTokens,
|
||||
collectionToken,
|
||||
collections,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
accessTokens: WarehouseAccessTokensData['data']
|
||||
collections: WarehouseCollectionsData
|
||||
collectionToken: string
|
||||
projectRef: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const BASE_WAREHOUSE_URL = `https://api.warehouse.tech/api/events`
|
||||
const [testAccessToken, setTestAccessToken] = useState('')
|
||||
const [selectedCollection, setSelectedCollection] = useState(collectionToken || '')
|
||||
const [showAccessToken, setShowAccessToken] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCollection(collectionToken)
|
||||
}, [collectionToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (accessTokens.length > 0) {
|
||||
setTestAccessToken(accessTokens[0].token)
|
||||
}
|
||||
}, [accessTokens])
|
||||
|
||||
const selectedAccessToken = accessTokens.find((token) => token.token === testAccessToken)
|
||||
const selectedCollectionName = collections?.find((col) => col.token === selectedCollection)?.name
|
||||
|
||||
function getcURL(accessToken: string) {
|
||||
return `curl -X "POST" "${BASE_WAREHOUSE_URL}?source=${selectedCollection}" \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'Authorization: Bearer ${accessToken}' \\
|
||||
-d $'{
|
||||
"event_message": "Test event message",
|
||||
"metadata": {
|
||||
"ip_address": "100.100.100.100",
|
||||
"request_method": "POST",
|
||||
"custom_user_data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"datacenter": "aws",
|
||||
"request_headers": {
|
||||
"connection": "close",
|
||||
"user_agent": "chrome"
|
||||
}
|
||||
}
|
||||
}'
|
||||
`
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SheetTrigger asChild>
|
||||
<Button disabled={accessTokens.length === 0} type="outline">
|
||||
Connect
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</TooltipTrigger>
|
||||
{accessTokens.length === 0 && (
|
||||
<TooltipContent>Create an access token to connect to your collection</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
<SheetContent size="lg" className="h-screen overflow-auto flex flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Send events to this collection</SheetTitle>
|
||||
<SheetDescription>
|
||||
Use the following cURL command to send events to this collection
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetSection className="flex flex-col gap-4 overflow-auto">
|
||||
<FormItemLayout label="Collection" isReactForm={false} layout="horizontal">
|
||||
<Select value={selectedCollection} onValueChange={setSelectedCollection}>
|
||||
<SelectTrigger size="tiny">
|
||||
<span className="truncate">{selectedCollectionName || 'Collection'}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[260px]">
|
||||
{collections?.map((col) => (
|
||||
<SelectItem key={col.id} value={col.token}>
|
||||
{col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItemLayout>
|
||||
<FormItemLayout label="Token" isReactForm={false} layout="horizontal">
|
||||
<Select value={testAccessToken} onValueChange={setTestAccessToken}>
|
||||
<SelectTrigger size="tiny">
|
||||
<span className="truncate">
|
||||
{selectedAccessToken?.description || 'Access token'}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[260px]">
|
||||
{accessTokens?.map((token: any) => (
|
||||
<SelectItem key={token.id + '-token'} value={token.token}>
|
||||
{token.description || 'No description'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItemLayout>
|
||||
|
||||
<FormItemLayout layout="horizontal" label="Ingest API URL" isReactForm={false}>
|
||||
<Input_Shadcn_ size="tiny" className="font-mono" value={BASE_WAREHOUSE_URL} readOnly />
|
||||
</FormItemLayout>
|
||||
<FormItemLayout layout="horizontal" label="Collection ID" isReactForm={false}>
|
||||
<Input_Shadcn_ size="tiny" className="font-mono" value={selectedCollection} readOnly />
|
||||
</FormItemLayout>
|
||||
</SheetSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SheetSection className="flex-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Checkbox_Shadcn_
|
||||
name="showAccessToken"
|
||||
id="showAccessToken"
|
||||
checked={showAccessToken}
|
||||
onCheckedChange={() => setShowAccessToken(!showAccessToken)}
|
||||
/>
|
||||
<Label_Shadcn_ htmlFor="showAccessToken">Show access token</Label_Shadcn_>
|
||||
</div>
|
||||
<CopyButton type="default" text={getcURL(testAccessToken)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 relative">
|
||||
<CodeBlock
|
||||
hideCopy
|
||||
className={'p-1 language-bash prose max-w-full transition-colors'}
|
||||
language="bash"
|
||||
>
|
||||
{getcURL(showAccessToken ? testAccessToken : '********************')}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetFooter className="mt-auto">
|
||||
<p className="text-sm text-foreground-light mr-auto">
|
||||
Once you send an event, refresh the page to see it in the collection.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onSubmit}>Refresh results</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
import { useKeyboardShortcuts } from 'components/grid/components/common/Hooks'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import LoadingOpacity from 'components/ui/LoadingOpacity'
|
||||
import ShimmerLine from 'components/ui/ShimmerLine'
|
||||
import { useWarehouseAccessTokensQuery } from 'data/analytics/warehouse-access-tokens-query'
|
||||
import { useWarehouseLogDetailQuery } from 'data/analytics/warehouse-collection-log-detail-query'
|
||||
import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query'
|
||||
import { useWarehouseQueryQuery } from 'data/analytics/warehouse-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { RefreshCcw, Rewind, Terminal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { LogsDatePicker } from '../Settings/Logs/Logs.DatePickers'
|
||||
import { DatetimeHelper } from '../Settings/Logs/Logs.types'
|
||||
import LogTable from '../Settings/Logs/LogTable'
|
||||
import { TestCollectionDialog } from './TestCollectionDialog'
|
||||
|
||||
const INTERVALS: DatetimeHelper[] = [
|
||||
{
|
||||
text: 'Last hour',
|
||||
calcFrom: () => dayjs().subtract(1, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
text: 'Last 12 hours',
|
||||
calcFrom: () => dayjs().subtract(12, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last day',
|
||||
calcFrom: () => dayjs().subtract(1, 'day').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last 7 days',
|
||||
calcFrom: () => dayjs().subtract(7, 'day').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
export const WarehouseCollectionDetail = () => {
|
||||
const router = useRouter()
|
||||
const collectionToken = router.query.collectionToken as string
|
||||
const projectRef = router.query.ref as string
|
||||
const accessTokens = useWarehouseAccessTokensQuery({ projectRef })
|
||||
const [testDialogOpen, setTestDialogOpen] = useState(false)
|
||||
const hasWarehouse = useFlag('warehouse')
|
||||
|
||||
const { data: collections, isLoading: collectionsLoading } = useWarehouseCollectionsQuery(
|
||||
{ projectRef },
|
||||
{ enabled: hasWarehouse }
|
||||
)
|
||||
const collection = collections?.find((c) => c.token === collectionToken)
|
||||
const [search, setSearch] = useState('')
|
||||
const [filters, setFilters] = useState({
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
search: '',
|
||||
interval: {
|
||||
to: INTERVALS[0].calcTo,
|
||||
from: INTERVALS[0].calcFrom,
|
||||
},
|
||||
})
|
||||
const [sql, setSQL] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (collection) {
|
||||
const from = filters.interval.from()
|
||||
const to = filters.interval.to()
|
||||
|
||||
const sql = `select id, timestamp from \`${collection.name}\`
|
||||
where timestamp >= TIMESTAMP('${from}')
|
||||
and timestamp <= TIMESTAMP('${to}')
|
||||
order by timestamp desc limit ${filters.limit} offset ${filters.offset}
|
||||
`
|
||||
|
||||
setSQL(sql)
|
||||
}
|
||||
}, [filters.interval, filters.limit, filters.offset, collection])
|
||||
|
||||
const {
|
||||
isLoading: queryLoading,
|
||||
data: queryData,
|
||||
isError,
|
||||
isRefetching,
|
||||
} = useWarehouseQueryQuery(
|
||||
{ ref: projectRef, sql },
|
||||
{
|
||||
enabled: !!sql && hasWarehouse,
|
||||
}
|
||||
)
|
||||
|
||||
const formatResults = (results: any) => {
|
||||
if (!results || !results.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const r = results.map(({ timestamp, ...r }: any) => {
|
||||
return {
|
||||
timestamp: new Date(timestamp / 1000).toLocaleString(),
|
||||
...r,
|
||||
}
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
const results = formatResults(queryData?.result)
|
||||
|
||||
function loadMore() {
|
||||
setFilters({ ...filters, offset: filters.offset + filters.limit })
|
||||
}
|
||||
|
||||
const isLoading = queryLoading || collectionsLoading || isRefetching
|
||||
|
||||
function refreshResults(e?: React.FormEvent<HTMLFormElement>) {
|
||||
e?.preventDefault()
|
||||
setFilters({
|
||||
...filters,
|
||||
search,
|
||||
interval: {
|
||||
to: () => dayjs().toISOString(),
|
||||
from: () => filters.interval.from() || dayjs().subtract(1, 'hour').toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const queryUrl = `/project/${projectRef}/logs/explorer?q=${encodeURIComponent(
|
||||
sql || ''
|
||||
)}&source=warehouse`
|
||||
|
||||
useKeyboardShortcuts(
|
||||
{
|
||||
r: () => {
|
||||
document.getElementById('refresh-button')?.focus()
|
||||
},
|
||||
s: () => {
|
||||
document.getElementById('search-input')?.focus()
|
||||
},
|
||||
},
|
||||
['INPUT', 'BUTTON', 'TEXTAREA']
|
||||
)
|
||||
|
||||
function KeyboardShortcut({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 font-medium text-xs font-mono text-foreground-light border rounded-md px-1.5 py-0.5 border-b-2">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Selected Log Detail
|
||||
const [selectedLogId, setSelectedLogId] = useSelectedLog()
|
||||
const {
|
||||
data: selectedLogDetail,
|
||||
isLoading: selectedLogDetailLoading,
|
||||
error: selectedLogDetailError,
|
||||
} = useWarehouseLogDetailQuery({
|
||||
ref: projectRef,
|
||||
collectionName: collection?.name,
|
||||
logId: selectedLogId ?? undefined,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col flex-grow h-full">
|
||||
<ShimmerLine active={isLoading} />
|
||||
<LoadingOpacity active={isLoading}>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex justify-between items-center h-12 px-5">
|
||||
<form onSubmit={refreshResults} className="flex items-center gap-2">
|
||||
<h2 className="text-foreground-light max-w-60 truncate" title={collection?.name}>
|
||||
{collection?.name}
|
||||
</h2>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
id="refresh-button"
|
||||
htmlType="submit"
|
||||
icon={<RefreshCcw />}
|
||||
type="text"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
className="px-1.5"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-2">
|
||||
Refresh <KeyboardShortcut>R</KeyboardShortcut>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* <Input
|
||||
type="search"
|
||||
id="search-input"
|
||||
className="w-52"
|
||||
size="tiny"
|
||||
placeholder="Search by event message"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/> */}
|
||||
<LogsDatePicker
|
||||
helpers={INTERVALS}
|
||||
value={{
|
||||
from: filters.interval.from(),
|
||||
to: filters.interval.to(),
|
||||
}}
|
||||
onSubmit={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
interval: {
|
||||
to: () => e.to || '',
|
||||
from: () => e.from || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild className="px-1.5" type="outline" icon={<Terminal />}>
|
||||
<Link href={queryUrl} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Open query in Collections Explorer
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<TestCollectionDialog
|
||||
accessTokens={accessTokens.data?.data || []}
|
||||
collectionToken={collectionToken}
|
||||
projectRef={projectRef}
|
||||
collections={collections || []}
|
||||
open={testDialogOpen}
|
||||
onOpenChange={setTestDialogOpen}
|
||||
onSubmit={() => {
|
||||
refreshResults()
|
||||
setTestDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="p-4">
|
||||
<AlertError subject="Failed to load query results" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isError && (
|
||||
<LogTable
|
||||
collectionName={collection?.name}
|
||||
queryType="warehouse"
|
||||
hasEditorValue={false}
|
||||
projectRef={projectRef}
|
||||
isLoading={isLoading}
|
||||
onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
|
||||
selectedLog={selectedLogDetail?.result[0] ?? undefined}
|
||||
selectedLogError={selectedLogDetailError ?? undefined}
|
||||
isSelectedLogLoading={!!selectedLogId && selectedLogDetailLoading}
|
||||
data={results}
|
||||
showHeader={false}
|
||||
EmptyState={
|
||||
<ProductEmptyState title="No events found" size="large">
|
||||
<div className="space-y-4">
|
||||
<p>Try adjusting your filters, send a test event or refresh the results.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTestDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
Send test event
|
||||
</Button>
|
||||
<Button type="outline" onClick={() => refreshResults()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ProductEmptyState>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LoadingOpacity>
|
||||
|
||||
{!isError && (
|
||||
<div className="border-t flex flex-row justify-between">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{results.length > 0 && (
|
||||
<Button
|
||||
onClick={loadMore}
|
||||
icon={<Rewind />}
|
||||
type="default"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
)}
|
||||
{filters.offset !== 0 && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setFilters({ ...filters, offset: 0 })}
|
||||
type="default"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Load latest
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Database, EditIcon, TrashIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useDeleteCollectionMutation } from 'data/analytics/warehouse-collections-delete-mutation'
|
||||
import { useUpdateCollection } from 'data/analytics/warehouse-collections-update-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
Modal,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { LogsSidebarItem } from '../Settings/Logs/SidebarV2/SidebarItem'
|
||||
import { CollectionForm } from './CollectionForm'
|
||||
|
||||
type Props = {
|
||||
item: {
|
||||
id: number
|
||||
token: string
|
||||
name: string
|
||||
retention_days: number
|
||||
}
|
||||
}
|
||||
|
||||
export const WarehouseMenuItem = ({ item }: Props) => {
|
||||
const router = useRouter()
|
||||
const { ref, collectionToken } = useParams()
|
||||
const projectRef = ref || 'default'
|
||||
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
|
||||
const [showDeleteDialog, setDeleteDialog] = useState(false)
|
||||
|
||||
const canUpdateCollection = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare')
|
||||
|
||||
const updateCollection = useUpdateCollection({
|
||||
onSuccess: () => {
|
||||
toast.success('Collection updated successfully')
|
||||
setShowUpdateDialog(false)
|
||||
},
|
||||
})
|
||||
const deleteCollection = useDeleteCollectionMutation({
|
||||
onSuccess: () => {
|
||||
// If the current collection is deleted, redirect to default logs view
|
||||
if (item.token === collectionToken) {
|
||||
router.push(`/project/${projectRef}/logs/explorer`)
|
||||
}
|
||||
setDeleteDialog(false)
|
||||
toast.success('Collection deleted successfully')
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = updateCollection.isLoading || deleteCollection.isLoading
|
||||
|
||||
return (
|
||||
<>
|
||||
<LogsSidebarItem
|
||||
isActive={item.token === collectionToken}
|
||||
label={item.name}
|
||||
icon={<Database size="14" className="text-foreground-light" />}
|
||||
href={`/project/${projectRef}/logs/collections/${item.token}`}
|
||||
dropdownItems={
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
disabled={!canUpdateCollection}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setShowUpdateDialog(true)
|
||||
}}
|
||||
>
|
||||
<EditIcon className="mr-2" size={14} />
|
||||
<div>Update collection</div>
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateCollection && (
|
||||
<TooltipContent side="right">
|
||||
You need additional permissions to update collections
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<DropdownMenuSeparator />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
disabled={!canUpdateCollection}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setDeleteDialog(true)
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="mr-2" size={14} />
|
||||
<div>Delete collection</div>
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateCollection && (
|
||||
<TooltipContent side="right">
|
||||
You need additional permissions to delete collections
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
></LogsSidebarItem>
|
||||
|
||||
<Modal
|
||||
size="medium"
|
||||
visible={showUpdateDialog}
|
||||
onCancel={() => setShowUpdateDialog(false)}
|
||||
hideFooter
|
||||
header={`Update collection "${item.name}"`}
|
||||
>
|
||||
<CollectionForm
|
||||
onCancelClick={() => setShowUpdateDialog(false)}
|
||||
isLoading={isLoading}
|
||||
initialValues={{ name: item.name, retention_days: item.retention_days }}
|
||||
onSubmit={({ name, retention_days }) => {
|
||||
updateCollection.mutate({
|
||||
projectRef,
|
||||
collectionToken: item.token,
|
||||
name,
|
||||
retention_days,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={showDeleteDialog}
|
||||
onCancel={() => setDeleteDialog(false)}
|
||||
confirmLabel="Delete collection"
|
||||
title={`Delete collection "${item.name}"`}
|
||||
size="small"
|
||||
onConfirm={() => {
|
||||
deleteCollection.mutate({ projectRef, collectionToken: item.token })
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
All data in this collection will be deleted.
|
||||
<br />
|
||||
<div className="mt-2">This action cannot be undone.</div>
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { motion } from 'framer-motion'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { ArrowUpRight, Circle, Database, MoreVertical, Plus, Search } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useMemo } from 'react'
|
||||
import ReactFlow, { Background, Handle, Position, ReactFlowProvider } from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { Button, Input } from 'ui'
|
||||
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Button, Input_Shadcn_ } from 'ui'
|
||||
import { NODE_WIDTH } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
|
||||
|
||||
const STATIC_NODES = [
|
||||
@@ -62,6 +64,14 @@ const STATIC_EDGES = [
|
||||
{ id: 'e1-4', source: '1', target: '4', type: 'smoothstep', animated: true },
|
||||
]
|
||||
|
||||
export const ReplicationComingSoon = () => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ReplicationStaticMockup />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const ReplicationStaticMockup = () => {
|
||||
const nodes = useMemo(() => STATIC_NODES, [])
|
||||
const edges = useMemo(() => STATIC_EDGES, [])
|
||||
@@ -108,16 +118,6 @@ const ReplicationStaticMockup = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const ReplicationComingSoon = () => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ReplicationStaticMockup />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReplicationComingSoon
|
||||
|
||||
const PrimaryNode = ({
|
||||
data,
|
||||
}: {
|
||||
@@ -251,58 +251,68 @@ const StaticDestinations = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col bg-surface-100 px-6 pt-6 border-t relative h-full">
|
||||
<div className="bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30"></div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Input
|
||||
size="small"
|
||||
className="w-52 bg-transparent"
|
||||
iconContainerClassName="pl-2"
|
||||
icon={<Search size={14} className="text-foreground-lighter" />}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<Button
|
||||
icon={<Plus size={16} />}
|
||||
type="primary"
|
||||
className="flex items-center"
|
||||
onClick={() => {}}
|
||||
>
|
||||
New destination
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="publication">Publication</Table.th>,
|
||||
<Table.th key="lag">Lag</Table.th>,
|
||||
<Table.th key="status">Status</Table.th>,
|
||||
<Table.th key="actions"></Table.th>,
|
||||
]}
|
||||
className="mt-4"
|
||||
body={mockRows.map((row, i) => (
|
||||
<Table.tr key={i}>
|
||||
<Table.td>{row.name}</Table.td>
|
||||
<Table.td>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-bold">All</span>
|
||||
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td>{row.lag}</Table.td>
|
||||
<Table.td>
|
||||
<span className="flex items-center gap-3">
|
||||
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
|
||||
{row.status}
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td className="text-right">
|
||||
<button className="p-1">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
))}
|
||||
/>
|
||||
<div className="flex flex-col bg-surface-100 px-6 py-6 border-t relative ">
|
||||
<div className="z-10 bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30" />
|
||||
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection className="!py-0">
|
||||
<div className="col-span-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-52">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
|
||||
/>
|
||||
<Input_Shadcn_
|
||||
className="pl-9 bg-transparent h-8 pointer-events-none"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
className="flex items-center pointer-events-none"
|
||||
>
|
||||
New destination
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="publication">Publication</Table.th>,
|
||||
<Table.th key="lag">Lag</Table.th>,
|
||||
<Table.th key="status">Status</Table.th>,
|
||||
<Table.th key="actions"></Table.th>,
|
||||
]}
|
||||
className="mt-4"
|
||||
body={mockRows.map((row, i) => (
|
||||
<Table.tr key={i}>
|
||||
<Table.td>{row.name}</Table.td>
|
||||
<Table.td>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-bold">All</span>
|
||||
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td>{row.lag}</Table.td>
|
||||
<Table.td>
|
||||
<span className="flex items-center gap-3">
|
||||
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
|
||||
{row.status}
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td className="text-right">
|
||||
<button className="p-1">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation'
|
||||
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
|
||||
import { ReplicationPipelinesData } from 'data/replication/pipelines-query'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from 'state/replication-pipeline-request-status'
|
||||
import { ResponseError } from 'types'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import RowMenu from './RowMenu'
|
||||
import PipelineStatus, { PipelineStatusRequestStatus, PipelineStatusName } from './PipelineStatus'
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
ReplicationPipelineStatusData,
|
||||
useReplicationPipelineStatusQuery,
|
||||
} from 'data/replication/pipeline-status-query'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation'
|
||||
import DeleteDestination from './DeleteDestination'
|
||||
import DestinationPanel from './DestinationPanel'
|
||||
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { PipelineStatus, PipelineStatusName } from './PipelineStatus'
|
||||
import { RowMenu } from './RowMenu'
|
||||
|
||||
export type Pipeline = ReplicationPipelinesData['pipelines'][0]
|
||||
|
||||
const refreshFrequencyMs: number = 5000
|
||||
const refreshFrequencyMs: number = 2000
|
||||
|
||||
interface DestinationRowProps {
|
||||
sourceId: number | undefined
|
||||
@@ -32,9 +34,10 @@ interface DestinationRowProps {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
isSuccess: boolean
|
||||
onSelectPipeline?: (pipelineId: number, destinationName: string) => void
|
||||
}
|
||||
|
||||
const DestinationRow = ({
|
||||
export const DestinationRow = ({
|
||||
sourceId,
|
||||
destinationId,
|
||||
destinationName,
|
||||
@@ -44,6 +47,7 @@ const DestinationRow = ({
|
||||
isLoading: isPipelineLoading,
|
||||
isError: isPipelineError,
|
||||
isSuccess: isPipelineSuccess,
|
||||
onSelectPipeline,
|
||||
}: DestinationRowProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
|
||||
@@ -62,76 +66,23 @@ const DestinationRow = ({
|
||||
},
|
||||
{ refetchInterval: refreshFrequencyMs }
|
||||
)
|
||||
const [requestStatus, setRequestStatus] = useState<PipelineStatusRequestStatus>(
|
||||
PipelineStatusRequestStatus.None
|
||||
)
|
||||
const { mutateAsync: startPipeline } = useStartPipelineMutation()
|
||||
const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus()
|
||||
const requestStatus = pipeline?.id
|
||||
? getRequestStatus(pipeline.id)
|
||||
: PipelineStatusRequestStatus.None
|
||||
|
||||
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
|
||||
const pipelineStatus = pipelineStatusData?.status
|
||||
const getStatusName = (
|
||||
status: ReplicationPipelineStatusData['status'] | undefined
|
||||
): string | undefined => {
|
||||
if (status && typeof status === 'object' && 'name' in status) {
|
||||
return status.name
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const statusName = getStatusName(pipelineStatus)
|
||||
if (
|
||||
(requestStatus === PipelineStatusRequestStatus.EnableRequested &&
|
||||
(statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)) ||
|
||||
(requestStatus === PipelineStatusRequestStatus.DisableRequested &&
|
||||
(statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED))
|
||||
) {
|
||||
setRequestStatus(PipelineStatusRequestStatus.None)
|
||||
}
|
||||
|
||||
const onEnableClick = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error('No pipeline found')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
} catch (error) {
|
||||
toast.error('Failed to enable destination')
|
||||
}
|
||||
setRequestStatus(PipelineStatusRequestStatus.EnableRequested)
|
||||
}
|
||||
const onDisableClick = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error('No pipeline found')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
} catch (error) {
|
||||
toast.error('Failed to disable destination')
|
||||
}
|
||||
setRequestStatus(PipelineStatusRequestStatus.DisableRequested)
|
||||
}
|
||||
const { mutateAsync: deleteDestination } = useDeleteDestinationMutation({})
|
||||
|
||||
const pipelineStatus = pipelineStatusData?.status
|
||||
const statusName = getStatusName(pipelineStatus)
|
||||
|
||||
const onDeleteClick = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
return console.error('Project ref is required')
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error('No pipeline found')
|
||||
return
|
||||
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -140,24 +91,33 @@ const DestinationRow = ({
|
||||
// so we don't need to call deletePipeline explicitly
|
||||
await deleteDestination({ projectRef, destinationId: destinationId })
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete destination')
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pipeline?.id) {
|
||||
updatePipelineStatus(pipeline.id, statusName)
|
||||
}
|
||||
}, [pipeline?.id, statusName, updatePipelineStatus])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPipelineError && (
|
||||
<AlertError error={pipelineError} subject="Failed to retrieve pipeline" />
|
||||
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
|
||||
)}
|
||||
{isPipelineSuccess && (
|
||||
<Table.tr>
|
||||
<Table.td>
|
||||
{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : destinationName}
|
||||
</Table.td>
|
||||
<Table.td>{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : type}</Table.td>
|
||||
<Table.tr
|
||||
className="hover:!bg-surface-200 transition"
|
||||
onClick={() => {
|
||||
if (pipeline) onSelectPipeline?.(pipeline.id, destinationName)
|
||||
}}
|
||||
>
|
||||
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : destinationName}</Table.td>
|
||||
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : type}</Table.td>
|
||||
<Table.td>
|
||||
{isPipelineLoading || !pipeline ? (
|
||||
<ShimmeringLoader></ShimmeringLoader>
|
||||
<ShimmeringLoader />
|
||||
) : (
|
||||
<PipelineStatus
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
@@ -166,27 +126,28 @@ const DestinationRow = ({
|
||||
isError={isPipelineStatusError}
|
||||
isSuccess={isPipelineStatusSuccess}
|
||||
requestStatus={requestStatus}
|
||||
></PipelineStatus>
|
||||
/>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{isPipelineLoading || !pipeline ? (
|
||||
<ShimmeringLoader></ShimmeringLoader>
|
||||
<ShimmeringLoader />
|
||||
) : (
|
||||
pipeline.config.publication_name
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<RowMenu
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
error={pipelineStatusError}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isError={isPipelineStatusError}
|
||||
onEnableClick={onEnableClick}
|
||||
onDisableClick={onDisableClick}
|
||||
onDeleteClick={() => setShowDeleteDestinationForm(true)}
|
||||
onEditClick={() => setShowEditDestinationPanel(true)}
|
||||
></RowMenu>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RowMenu
|
||||
pipeline={pipeline}
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
error={pipelineStatusError}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isError={isPipelineStatusError}
|
||||
onDeleteClick={() => setShowDeleteDestinationForm(true)}
|
||||
onEditClick={() => setShowEditDestinationPanel(true)}
|
||||
/>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
@@ -211,5 +172,3 @@ const DestinationRow = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DestinationRow
|
||||
@@ -1,19 +1,26 @@
|
||||
import { noop } from 'lodash'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { useReplicationDestinationsQuery } from 'data/replication/destinations-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import DestinationRow from './DestinationRow'
|
||||
import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query'
|
||||
import { useState } from 'react'
|
||||
import NewDestinationPanel from './DestinationPanel'
|
||||
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { Button, cn, Input_Shadcn_ } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import NewDestinationPanel from './DestinationPanel'
|
||||
import { DestinationRow } from './DestinationRow'
|
||||
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
|
||||
const Destinations = () => {
|
||||
interface DestinationsProps {
|
||||
onSelectPipeline?: (pipelineId: number, destinationName: string) => void
|
||||
}
|
||||
|
||||
export const Destinations = ({ onSelectPipeline = noop }: DestinationsProps) => {
|
||||
const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false)
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const {
|
||||
@@ -21,12 +28,11 @@ const Destinations = () => {
|
||||
error: sourcesError,
|
||||
isLoading: isSourcesLoading,
|
||||
isError: isSourcesError,
|
||||
isSuccess: isSourcesSuccess,
|
||||
} = useReplicationSourcesQuery({
|
||||
projectRef,
|
||||
})
|
||||
|
||||
let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
|
||||
const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
|
||||
|
||||
const {
|
||||
data: destinationsData,
|
||||
@@ -50,21 +56,44 @@ const Destinations = () => {
|
||||
|
||||
const anyDestinations = isDestinationsSuccess && destinationsData.destinations.length > 0
|
||||
|
||||
const filteredDestinations =
|
||||
filterString.length === 0
|
||||
? destinationsData?.destinations ?? []
|
||||
: (destinationsData?.destinations ?? []).filter((destination) =>
|
||||
destination.name.toLowerCase().includes(filterString.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<ScaffoldSectionTitle>Destinations</ScaffoldSectionTitle>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
|
||||
size={14}
|
||||
/>
|
||||
<Input_Shadcn_
|
||||
className="pl-9 h-7"
|
||||
placeholder={'Filter destinations'}
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="default" icon={<Plus />} onClick={() => setShowNewDestinationPanel(true)}>
|
||||
Add destination
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
{(isSourcesLoading || isDestinationsLoading) && <GenericSkeletonLoader />}
|
||||
|
||||
{(isSourcesError || isDestinationsError) && (
|
||||
<AlertError
|
||||
error={sourcesError || destinationsError}
|
||||
subject="Failed to retrieve destinations"
|
||||
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_DESTINATIONS}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -77,7 +106,7 @@ const Destinations = () => {
|
||||
<Table.th key="publication">Publication</Table.th>,
|
||||
<Table.th key="actions"></Table.th>,
|
||||
]}
|
||||
body={destinationsData.destinations.map((destination) => {
|
||||
body={filteredDestinations.map((destination) => {
|
||||
const pipeline = pipelinesData?.pipelines.find(
|
||||
(p) => p.destination_id === destination.id
|
||||
)
|
||||
@@ -93,7 +122,8 @@ const Destinations = () => {
|
||||
isLoading={isPipelinesLoading}
|
||||
isError={isPipelinesError}
|
||||
isSuccess={isPipelinesSuccess}
|
||||
></DestinationRow>
|
||||
onSelectPipeline={onSelectPipeline}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
></Table>
|
||||
@@ -124,15 +154,22 @@ const Destinations = () => {
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ScaffoldSection>
|
||||
</div>
|
||||
|
||||
{!isSourcesLoading &&
|
||||
!isDestinationsLoading &&
|
||||
filteredDestinations.length === 0 &&
|
||||
anyDestinations && (
|
||||
<div className="text-center py-8 text-foreground-light">
|
||||
<p>No destinations match "{filterString}"</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewDestinationPanel
|
||||
visible={showNewDestinationPanel}
|
||||
sourceId={sourceId}
|
||||
onClose={() => setShowNewDestinationPanel(false)}
|
||||
></NewDestinationPanel>
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Destinations
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
|
||||
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
|
||||
|
||||
export const PIPELINE_ERROR_MESSAGES = {
|
||||
RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information',
|
||||
RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status',
|
||||
RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status',
|
||||
RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations',
|
||||
ENABLE_DESTINATION: 'Failed to enable destination',
|
||||
DISABLE_DESTINATION: 'Failed to disable destination',
|
||||
DELETE_DESTINATION: 'Failed to delete destination',
|
||||
NO_PIPELINE_FOUND: 'No pipeline found',
|
||||
COPY_TABLE_STATUS: 'Failed to copy table status',
|
||||
} as const
|
||||
|
||||
export const getStatusName = (
|
||||
status: ReplicationPipelineStatusData['status'] | undefined
|
||||
): ReplicationPipelineStatusData['status']['name'] | undefined => {
|
||||
if (status && typeof status === 'object' && 'name' in status) {
|
||||
return status.name
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const PIPELINE_STATE_MESSAGES = {
|
||||
enabling: {
|
||||
title: 'Pipeline Enabling',
|
||||
message: 'Starting the pipeline. Table replication will resume once enabled.',
|
||||
badge: 'Enabling',
|
||||
},
|
||||
disabling: {
|
||||
title: 'Pipeline Disabling',
|
||||
message: 'Stopping the pipeline. Table replication will be paused once disabled.',
|
||||
badge: 'Disabling',
|
||||
},
|
||||
failed: {
|
||||
title: 'Pipeline Failed',
|
||||
message: 'Replication has encountered an error. Check the logs for details.',
|
||||
badge: 'Failed',
|
||||
},
|
||||
stopped: {
|
||||
title: 'Pipeline Stopped',
|
||||
message: 'Replication is paused. Enable the pipeline to resume data synchronization.',
|
||||
badge: 'Stopped',
|
||||
},
|
||||
starting: {
|
||||
title: 'Pipeline Starting',
|
||||
message: 'Initializing replication. Table status will be available once running.',
|
||||
badge: 'Starting',
|
||||
},
|
||||
running: {
|
||||
title: 'Pipeline Running',
|
||||
message: 'Replication is active and processing data',
|
||||
badge: 'Running',
|
||||
},
|
||||
unknown: {
|
||||
title: 'Pipeline Status Unknown',
|
||||
message: 'Unable to determine replication status. Check the logs for more information.',
|
||||
badge: 'Unknown',
|
||||
},
|
||||
notRunning: {
|
||||
title: 'Pipeline Not Running',
|
||||
message: 'Replication is not active. Enable the pipeline to start data synchronization.',
|
||||
badge: 'Disabled',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const getPipelineStateMessages = (
|
||||
requestStatus: PipelineStatusRequestStatus | undefined,
|
||||
statusName: string | undefined
|
||||
) => {
|
||||
// Always prioritize request status (enabling/disabling) over pipeline status
|
||||
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
|
||||
return PIPELINE_STATE_MESSAGES.enabling
|
||||
}
|
||||
|
||||
if (requestStatus === PipelineStatusRequestStatus.DisableRequested) {
|
||||
return PIPELINE_STATE_MESSAGES.disabling
|
||||
}
|
||||
|
||||
// Only check pipeline status if no request is in progress
|
||||
switch (statusName) {
|
||||
case 'failed':
|
||||
return PIPELINE_STATE_MESSAGES.failed
|
||||
case 'stopped':
|
||||
return PIPELINE_STATE_MESSAGES.stopped
|
||||
case 'starting':
|
||||
return PIPELINE_STATE_MESSAGES.starting
|
||||
case 'started':
|
||||
return PIPELINE_STATE_MESSAGES.running
|
||||
case 'unknown':
|
||||
return PIPELINE_STATE_MESSAGES.unknown
|
||||
default:
|
||||
return PIPELINE_STATE_MESSAGES.notRunning
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,11 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { cn } from 'ui'
|
||||
import { ResponseError } from 'types'
|
||||
import { Loader2, ChevronDown, ChevronRight, Copy, AlertTriangle } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Button,
|
||||
Collapsible_Shadcn_ as Collapsible,
|
||||
CollapsibleContent_Shadcn_ as CollapsibleContent,
|
||||
CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
|
||||
} from 'ui'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { copyToClipboard } from 'ui'
|
||||
import { toast } from 'sonner'
|
||||
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
|
||||
|
||||
export enum PipelineStatusRequestStatus {
|
||||
None = 'None',
|
||||
EnableRequested = 'EnableRequested',
|
||||
DisableRequested = 'DisableRequested',
|
||||
}
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
|
||||
import { ResponseError } from 'types'
|
||||
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { getPipelineStateMessages, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
|
||||
export enum PipelineStatusName {
|
||||
FAILED = 'failed',
|
||||
@@ -41,10 +24,10 @@ interface PipelineStatusProps {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
isSuccess: boolean
|
||||
requestStatus: PipelineStatusRequestStatus
|
||||
requestStatus?: PipelineStatusRequestStatus
|
||||
}
|
||||
|
||||
const PipelineStatus = ({
|
||||
export const PipelineStatus = ({
|
||||
pipelineStatus,
|
||||
error,
|
||||
isLoading,
|
||||
@@ -52,34 +35,6 @@ const PipelineStatus = ({
|
||||
isSuccess,
|
||||
requestStatus,
|
||||
}: PipelineStatusProps) => {
|
||||
const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false)
|
||||
const errorDetailsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (errorDetailsRef.current && !errorDetailsRef.current.contains(event.target as Node)) {
|
||||
setIsErrorDetailsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isErrorDetailsOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isErrorDetailsOpen])
|
||||
|
||||
const handleCopyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await copyToClipboard(text)
|
||||
toast.success('Error details copied to clipboard')
|
||||
} catch (error) {
|
||||
toast.error('Failed to copy error details')
|
||||
}
|
||||
}
|
||||
|
||||
const isFailedStatus = (
|
||||
status: ReplicationPipelineStatusData['status'] | undefined
|
||||
): status is FailedStatus => {
|
||||
@@ -91,144 +46,37 @@ const PipelineStatus = ({
|
||||
)
|
||||
}
|
||||
|
||||
const isLogLoadingState = (failedStatus: FailedStatus): boolean => {
|
||||
// Right now we hardcode the error message which is returned when k8s is not able to find logs, which seems
|
||||
// to be a transient error. In case we find a way to properly handle this in the backend, this hack will not
|
||||
// be needed anymore.
|
||||
const renderFailedStatus = () => {
|
||||
return (
|
||||
failedStatus.message?.startsWith('unable to retrieve container logs for containerd://') ===
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
const renderFailedStatus = (failedStatus: FailedStatus) => {
|
||||
const hasDetails =
|
||||
failedStatus.message || failedStatus.reason || failedStatus.exit_code !== undefined
|
||||
const isLoadingLogs = isLogLoadingState(failedStatus)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
{isLoadingLogs ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Failed</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isLoadingLogs
|
||||
? 'Pipeline failed - logs are being retrieved from container'
|
||||
: 'Pipeline has failed - expand for error details'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{hasDetails && (
|
||||
<Collapsible open={isErrorDetailsOpen} onOpenChange={setIsErrorDetailsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="text" size="tiny" className="h-auto py-0 px-1 text-xs">
|
||||
{isErrorDetailsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="absolute z-10 mt-1 left-0 min-w-[400px] max-w-[600px]">
|
||||
<div
|
||||
ref={errorDetailsRef}
|
||||
className="rounded-lg border border-border bg-surface-100 p-3 shadow-md"
|
||||
>
|
||||
{isLoadingLogs ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-warning-600" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
Pipeline Failed - Loading logs...
|
||||
</div>
|
||||
<div className="text-xs text-foreground-light">
|
||||
Error logs are being retrieved from the container. This may take a few
|
||||
moments.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Pipeline Error Details
|
||||
</span>
|
||||
{(failedStatus.message || failedStatus.reason) && (
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
onClick={async () => {
|
||||
await handleCopyToClipboard(
|
||||
[
|
||||
failedStatus.exit_code !== undefined
|
||||
? `Exit Code: ${failedStatus.exit_code}`
|
||||
: null,
|
||||
failedStatus.reason ? `Reason: ${failedStatus.reason}` : null,
|
||||
failedStatus.message ? `Message: ${failedStatus.message}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{failedStatus.exit_code !== undefined && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-foreground-light">
|
||||
Exit Code:
|
||||
</span>
|
||||
<div className="font-mono text-sm text-foreground mt-1">
|
||||
{failedStatus.exit_code}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{failedStatus.reason && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-foreground-light">Reason:</span>
|
||||
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
|
||||
{failedStatus.reason}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{failedStatus.message && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-foreground-light">
|
||||
Message:
|
||||
</span>
|
||||
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
|
||||
{failedStatus.message}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>Failed</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{getPipelineStateMessages(requestStatus, 'failed').message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
// Map backend statuses to UX-friendly display
|
||||
const getStatusConfig = () => {
|
||||
const statusName =
|
||||
pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus
|
||||
? pipelineStatus.name
|
||||
: undefined
|
||||
|
||||
// Get consistent tooltip message using the same logic as other components
|
||||
const stateMessages = getPipelineStateMessages(requestStatus, statusName)
|
||||
|
||||
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
|
||||
return {
|
||||
label: 'Enabling...',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-brand-600" />,
|
||||
color: 'text-brand-600',
|
||||
tooltip: 'Pipeline is being enabled and will start shortly',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +85,7 @@ const PipelineStatus = ({
|
||||
label: 'Disabling...',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
|
||||
color: 'text-warning-600',
|
||||
tooltip: 'Pipeline is being disabled and will stop shortly',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,35 +103,35 @@ const PipelineStatus = ({
|
||||
label: 'Starting',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
|
||||
color: 'text-warning-600',
|
||||
tooltip: 'Pipeline is initializing and will be ready soon',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
case PipelineStatusName.STARTED:
|
||||
return {
|
||||
label: 'Running',
|
||||
dot: <div className="w-2 h-2 bg-brand-600 rounded-full" />,
|
||||
color: 'text-brand-600',
|
||||
tooltip: 'Pipeline is active and processing data',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
case PipelineStatusName.STOPPED:
|
||||
return {
|
||||
label: 'Stopped',
|
||||
dot: <div className="w-2 h-2 bg-foreground-lighter rounded-full" />,
|
||||
color: 'text-foreground-light',
|
||||
tooltip: 'Pipeline is not running - enable to start processing',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
case PipelineStatusName.UNKNOWN:
|
||||
return {
|
||||
label: 'Unknown',
|
||||
dot: <div className="w-2 h-2 bg-warning-600 rounded-full" />,
|
||||
color: 'text-warning-600',
|
||||
tooltip: 'Pipeline status could not be determined',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: 'Unknown',
|
||||
dot: <div className="w-2 h-2 bg-destructive-600 rounded-full" />,
|
||||
color: 'text-destructive-600',
|
||||
tooltip: 'Pipeline status is unclear - check logs for details',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,31 +149,27 @@ const PipelineStatus = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
|
||||
{isLoading && <ShimmeringLoader />}
|
||||
{isError && (
|
||||
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
|
||||
)}
|
||||
{isSuccess && (
|
||||
<>
|
||||
{statusConfig.isFailedStatus ? (
|
||||
<div className="relative">{renderFailedStatus(pipelineStatus as FailedStatus)}</div>
|
||||
<div className="relative">{renderFailedStatus()}</div>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn('flex items-center gap-2 text-sm', statusConfig.color)}>
|
||||
{statusConfig.dot}
|
||||
<span>{statusConfig.label}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{statusConfig.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn('flex items-center gap-2 text-sm w-min', statusConfig.color)}>
|
||||
{statusConfig.dot}
|
||||
<span>{statusConfig.label}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{statusConfig.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipelineStatus
|
||||
@@ -0,0 +1,396 @@
|
||||
import { Activity, AlertTriangle, ChevronLeft, Copy, ExternalLink, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query'
|
||||
import { useReplicationPipelineReplicationStatusQuery } from 'data/replication/pipeline-replication-status-query'
|
||||
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from 'state/replication-pipeline-request-status'
|
||||
import { Badge, Button, cn, copyToClipboard, Input_Shadcn_ } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { PipelineStatus } from './PipelineStatus'
|
||||
import { TableState } from './ReplicationPipelineStatus.types'
|
||||
import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils'
|
||||
|
||||
interface ReplicationPipelineStatusProps {
|
||||
pipelineId: number
|
||||
destinationName?: string
|
||||
onSelectBack: () => void
|
||||
}
|
||||
|
||||
export const ReplicationPipelineStatus = ({
|
||||
pipelineId,
|
||||
destinationName,
|
||||
onSelectBack,
|
||||
}: ReplicationPipelineStatusProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
|
||||
const { getRequestStatus, updatePipelineStatus, setRequestStatus } = usePipelineRequestStatus()
|
||||
const requestStatus = getRequestStatus(pipelineId)
|
||||
|
||||
const {
|
||||
data: pipeline,
|
||||
error: pipelineError,
|
||||
isLoading: isPipelineLoading,
|
||||
isError: isPipelineError,
|
||||
} = useReplicationPipelineByIdQuery({
|
||||
projectRef,
|
||||
pipelineId,
|
||||
})
|
||||
|
||||
const {
|
||||
data: pipelineStatusData,
|
||||
error: pipelineStatusError,
|
||||
isLoading: isPipelineStatusLoading,
|
||||
isError: isPipelineStatusError,
|
||||
isSuccess: isPipelineStatusSuccess,
|
||||
} = useReplicationPipelineStatusQuery(
|
||||
{ projectRef, pipelineId },
|
||||
{
|
||||
enabled: !!pipelineId,
|
||||
refetchInterval: 2000, // Poll every 2 seconds
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
data: replicationStatusData,
|
||||
error: statusError,
|
||||
isLoading: isStatusLoading,
|
||||
isError: isStatusError,
|
||||
} = useReplicationPipelineReplicationStatusQuery(
|
||||
{ projectRef, pipelineId },
|
||||
{
|
||||
enabled: !!pipelineId,
|
||||
refetchInterval: 2000, // Poll every 2 seconds
|
||||
}
|
||||
)
|
||||
|
||||
const { mutateAsync: startPipeline, isLoading: isStartingPipeline } = useStartPipelineMutation()
|
||||
const { mutateAsync: stopPipeline, isLoading: isStoppingPipeline } = useStopPipelineMutation()
|
||||
|
||||
const statusName = getStatusName(pipelineStatusData?.status)
|
||||
const config = getDisabledStateConfig({ requestStatus, statusName })
|
||||
|
||||
const tableStatuses = replicationStatusData?.table_statuses || []
|
||||
const filteredTableStatuses =
|
||||
filterString.length === 0
|
||||
? tableStatuses
|
||||
: tableStatuses.filter((table: TableState) =>
|
||||
table.table_name.toLowerCase().includes(filterString.toLowerCase())
|
||||
)
|
||||
|
||||
const errorTables = tableStatuses.filter((table: TableState) => table.state.name === 'error')
|
||||
const hasErrors = errorTables.length > 0
|
||||
const isPipelineRunning = statusName === 'started'
|
||||
const hasTableData = tableStatuses.length > 0
|
||||
const isEnablingDisabling =
|
||||
requestStatus === PipelineStatusRequestStatus.EnableRequested ||
|
||||
requestStatus === PipelineStatusRequestStatus.DisableRequested
|
||||
const showDisabledState = !isPipelineRunning || isEnablingDisabling
|
||||
|
||||
const handleCopyTableStatus = async (tableName: string, state: TableState['state']) => {
|
||||
const statusText = `Table: ${tableName}\nStatus: ${state.name}${
|
||||
'message' in state ? `\nError: ${state.message}` : ''
|
||||
}${'lag' in state ? `\nLag: ${state.lag}ms` : ''}`
|
||||
|
||||
try {
|
||||
await copyToClipboard(statusText)
|
||||
toast.success('Table status copied to clipboard')
|
||||
} catch {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.COPY_TABLE_STATUS)
|
||||
}
|
||||
}
|
||||
|
||||
const onTogglePipeline = async () => {
|
||||
if (!projectRef) {
|
||||
return console.error('Project ref is required')
|
||||
}
|
||||
if (!pipeline) {
|
||||
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
}
|
||||
|
||||
try {
|
||||
if (statusName === 'stopped') {
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
|
||||
} else if (statusName === 'started') {
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updatePipelineStatus(pipelineId, statusName)
|
||||
}, [pipelineId, statusName, updatePipelineStatus])
|
||||
|
||||
if (isPipelineError) {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Button
|
||||
type="outline"
|
||||
onClick={onSelectBack}
|
||||
icon={<ChevronLeft />}
|
||||
style={{ padding: '5px' }}
|
||||
/>
|
||||
<h3 className="text-xl font-semibold">Pipeline Status</h3>
|
||||
</div>
|
||||
</div>
|
||||
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with back button and filters */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Button
|
||||
type="outline"
|
||||
onClick={onSelectBack}
|
||||
icon={<ChevronLeft />}
|
||||
style={{ padding: '5px' }}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<h3 className="text-xl font-semibold">{destinationName || 'Pipeline'}</h3>
|
||||
<PipelineStatus
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
error={pipelineStatusError}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isError={isPipelineStatusError}
|
||||
isSuccess={isPipelineStatusSuccess}
|
||||
requestStatus={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-2 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
|
||||
size={14}
|
||||
/>
|
||||
<Input_Shadcn_
|
||||
className="pl-7 h-[26px] text-xs"
|
||||
placeholder="Search for tables"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type={statusName === 'stopped' ? 'primary' : 'default'}
|
||||
onClick={() => onTogglePipeline()}
|
||||
loading={isStartingPipeline || isStoppingPipeline}
|
||||
disabled={!['failed', 'started', 'stopped'].includes(statusName ?? '')}
|
||||
>
|
||||
{statusName === 'stopped' ? 'Enable' : 'Disable'} pipeline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isPipelineLoading || isStatusLoading) && <GenericSkeletonLoader />}
|
||||
|
||||
{isStatusError && (
|
||||
<AlertError
|
||||
error={statusError}
|
||||
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_REPLICATION_STATUS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasErrors && (
|
||||
<div className="p-4 border border-destructive-300 bg-destructive-100 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive-600 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-destructive-900 mb-1">
|
||||
{errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed
|
||||
</h4>
|
||||
<p className="text-sm text-destructive-700 mb-3">
|
||||
Some tables encountered replication errors. Check the logs for detailed error
|
||||
information.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
type="outline"
|
||||
size="tiny"
|
||||
icon={<ExternalLink className="w-3 h-3" />}
|
||||
className="text-destructive-600 border-destructive-300 hover:bg-destructive-50"
|
||||
>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href={`/project/${projectRef}/logs/postgres-logs`}
|
||||
>
|
||||
View Logs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasTableData && (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{showDisabledState && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 border border-default rounded-lg flex items-center justify-between',
|
||||
config.colors.bg
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center',
|
||||
config.colors.iconBg
|
||||
)}
|
||||
>
|
||||
<div className={config.colors.icon}>{config.icon}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`text-sm font-medium ${config.colors.text}`}>{config.title}</h4>
|
||||
<p className={`text-sm ${config.colors.subtext}`}>{config.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="table">Table</Table.th>,
|
||||
<Table.th key="status">Status</Table.th>,
|
||||
<Table.th key="details">Details</Table.th>,
|
||||
<Table.th key="actions" className="w-16 text-center">
|
||||
Actions
|
||||
</Table.th>,
|
||||
]}
|
||||
body={filteredTableStatuses.map((table: TableState, index: number) => {
|
||||
const statusConfig = getStatusConfig(table.state)
|
||||
return (
|
||||
<Table.tr key={`${table.table_name}-${index}`} className="border-t">
|
||||
<Table.td>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p>{table.table_name}</p>
|
||||
|
||||
<ButtonTooltip
|
||||
asChild
|
||||
type="text"
|
||||
className="px-1.5"
|
||||
icon={<ExternalLink />}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }}
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/project/${projectRef}/editor/${table.table_id}`}
|
||||
/>
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{showDisabledState ? (
|
||||
<Badge variant="default">Not Available</Badge>
|
||||
) : (
|
||||
statusConfig.badge
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{showDisabledState ? (
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Status unavailable while pipeline is {config.badge.toLowerCase()}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-foreground">{statusConfig.description}</div>
|
||||
{'lag' in table.state && (
|
||||
<div className="text-xs text-foreground-light">
|
||||
Lag: {table.state.lag}ms
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td className="text-center">
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<Copy className="w-3 h-3" />}
|
||||
className="px-1.5"
|
||||
disabled={showDisabledState}
|
||||
onClick={() => handleCopyTableStatus(table.table_name, table.state)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: showDisabledState
|
||||
? `Copy unavailable while pipeline is ${config.badge.toLowerCase()}`
|
||||
: 'Copy status details',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTableStatuses.length === 0 && hasTableData && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{filterString}" did not return any results
|
||||
</p>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
|
||||
{!isStatusLoading && tableStatuses.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||||
<div className="w-full max-w-sm mx-auto text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-surface-200 rounded-full flex items-center justify-center mx-auto">
|
||||
<Activity className="w-8 h-8 text-foreground-lighter" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-lg font-semibold text-foreground">
|
||||
{showDisabledState ? 'Pipeline Not Running' : 'No table status information'}
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-light leading-relaxed">
|
||||
{showDisabledState
|
||||
? `The replication pipeline is currently ${statusName || 'not active'}. Table status
|
||||
information is not available while the pipeline is in this state.`
|
||||
: `This pipeline doesn't have any table replication status data available yet. The status will appear here once replication begins.`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Data refreshes automatically every 2 seconds
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export type TableState = {
|
||||
table_id: number
|
||||
table_name: string
|
||||
state:
|
||||
| { name: 'queued' }
|
||||
| { name: 'copying_table' }
|
||||
| { name: 'copied_table' }
|
||||
| { name: 'following_wal'; lag: number }
|
||||
| { name: 'error'; message: string }
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
|
||||
import { Activity, Clock, HelpCircle, Loader2, XCircle } from 'lucide-react'
|
||||
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
|
||||
import { Badge } from 'ui'
|
||||
import { getPipelineStateMessages } from './Pipeline.utils'
|
||||
import { TableState } from './ReplicationPipelineStatus.types'
|
||||
|
||||
export const getStatusConfig = (state: TableState['state']) => {
|
||||
switch (state.name) {
|
||||
case 'queued':
|
||||
return {
|
||||
badge: <Badge variant="warning">Queued</Badge>,
|
||||
description: 'Waiting to start replication',
|
||||
color: 'text-warning-600',
|
||||
}
|
||||
case 'copying_table':
|
||||
return {
|
||||
badge: <Badge variant="brand">Copying</Badge>,
|
||||
description: 'Initial data copy in progress',
|
||||
color: 'text-brand-600',
|
||||
}
|
||||
case 'copied_table':
|
||||
return {
|
||||
badge: <Badge variant="success">Copied</Badge>,
|
||||
description: 'Initial copy completed',
|
||||
color: 'text-success-600',
|
||||
}
|
||||
case 'following_wal':
|
||||
return {
|
||||
badge: <Badge variant="success">Live</Badge>,
|
||||
description: `Replicating live changes`,
|
||||
color: 'text-success-600',
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
badge: <Badge variant="destructive">Error</Badge>,
|
||||
description: state.message,
|
||||
color: 'text-destructive-600',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
badge: <Badge variant="warning">Unknown</Badge>,
|
||||
description: 'Unknown status',
|
||||
color: 'text-warning-600',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getDisabledStateConfig = ({
|
||||
requestStatus,
|
||||
statusName,
|
||||
}: {
|
||||
requestStatus: PipelineStatusRequestStatus
|
||||
statusName?: ReplicationPipelineStatusData['status']['name']
|
||||
}) => {
|
||||
const { title, message, badge } = getPipelineStateMessages(requestStatus, statusName)
|
||||
|
||||
// Get icon and colors based on current state
|
||||
const isEnabling = requestStatus === PipelineStatusRequestStatus.EnableRequested
|
||||
const isDisabling = requestStatus === PipelineStatusRequestStatus.DisableRequested
|
||||
const isTransitioning = isEnabling || isDisabling
|
||||
|
||||
const icon = isTransitioning ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
) : statusName === 'failed' ? (
|
||||
<XCircle className="w-6 h-6" />
|
||||
) : statusName === 'starting' ? (
|
||||
<Clock className="w-6 h-6" />
|
||||
) : statusName === 'unknown' ? (
|
||||
<HelpCircle className="w-6 h-6" />
|
||||
) : (
|
||||
<Activity className="w-6 h-6" />
|
||||
)
|
||||
|
||||
const colors = isEnabling
|
||||
? {
|
||||
bg: 'bg-brand-50',
|
||||
text: 'text-brand-900',
|
||||
subtext: 'text-brand-700',
|
||||
iconBg: 'bg-brand-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: isDisabling || statusName === 'starting' || statusName === 'unknown'
|
||||
? {
|
||||
bg: 'bg-warning-50',
|
||||
text: 'text-warning-900',
|
||||
subtext: 'text-warning-700',
|
||||
iconBg: 'bg-warning-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: statusName === 'failed'
|
||||
? {
|
||||
bg: 'bg-destructive-50',
|
||||
text: 'text-destructive-900',
|
||||
subtext: 'text-destructive-700',
|
||||
iconBg: 'bg-destructive-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: {
|
||||
bg: 'bg-surface-100',
|
||||
text: 'text-foreground',
|
||||
subtext: 'text-foreground-light',
|
||||
iconBg: 'bg-foreground-lighter',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
|
||||
return { title, message, badge, icon, colors }
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from 'state/replication-pipeline-request-status'
|
||||
import { ResponseError } from 'types'
|
||||
import {
|
||||
Button,
|
||||
@@ -10,29 +19,31 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { Pipeline } from './DestinationRow'
|
||||
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { PipelineStatusName } from './PipelineStatus'
|
||||
|
||||
interface RowMenuProps {
|
||||
pipeline: Pipeline | undefined
|
||||
pipelineStatus: any
|
||||
error: ResponseError | null
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
onEnableClick: () => void
|
||||
onDisableClick: () => void
|
||||
onEditClick: () => void
|
||||
onDeleteClick: () => void
|
||||
}
|
||||
|
||||
const RowMenu = ({
|
||||
export const RowMenu = ({
|
||||
pipeline,
|
||||
pipelineStatus,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
onEnableClick,
|
||||
onDisableClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}: RowMenuProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const getStatusName = (status: any) => {
|
||||
if (status && typeof status === 'object' && 'name' in status) {
|
||||
return status.name
|
||||
@@ -43,33 +54,104 @@ const RowMenu = ({
|
||||
const statusName = getStatusName(pipelineStatus)
|
||||
const pipelineEnabled = statusName !== PipelineStatusName.STOPPED
|
||||
|
||||
const { mutateAsync: startPipeline } = useStartPipelineMutation()
|
||||
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
|
||||
const { setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus()
|
||||
|
||||
const onEnablePipeline = async () => {
|
||||
if (!projectRef) {
|
||||
return console.error('Project ref is required')
|
||||
}
|
||||
if (!pipeline) {
|
||||
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
}
|
||||
|
||||
try {
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
toast(`Enabling pipeline ${pipeline.destination_name}`)
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
|
||||
} catch (error) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
|
||||
}
|
||||
}
|
||||
|
||||
const onDisablePipeline = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
toast(`Disabling pipeline ${pipeline.destination_name}`)
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
|
||||
} catch (error) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
|
||||
{isLoading && <ShimmeringLoader />}
|
||||
{isError && (
|
||||
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1.5"
|
||||
icon={<MoreVertical />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-52">
|
||||
{pipelineEnabled ? (
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDisableClick}>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDisablePipeline()
|
||||
}}
|
||||
>
|
||||
<Pause size={14} />
|
||||
<p>Disable</p>
|
||||
<p>Disable pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem className="space-x-2" onClick={onEnableClick}>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEnablePipeline()
|
||||
}}
|
||||
>
|
||||
<Play size={14} />
|
||||
<p>Enable</p>
|
||||
<p>Enable pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="space-x-2" onClick={onEditClick}>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditClick()
|
||||
}}
|
||||
>
|
||||
<Edit size={14} />
|
||||
<p>Edit destination</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDeleteClick}>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeleteClick()
|
||||
}}
|
||||
>
|
||||
<Trash stroke="red" size={14} />
|
||||
<p>Delete destination</p>
|
||||
</DropdownMenuItem>
|
||||
@@ -78,5 +160,3 @@ const RowMenu = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RowMenu
|
||||
@@ -42,7 +42,7 @@ export const MarkdownContent = ({ integrationId }: { integrationId: string }) =>
|
||||
/>
|
||||
)}
|
||||
{supportExpanding && (
|
||||
<div className={cn('bottom-0 z-10', !isExpanded ? 'absolute' : 'relative mt-3')}>
|
||||
<div className={cn('bottom-0', !isExpanded ? 'absolute' : 'relative mt-3')}>
|
||||
<button
|
||||
className="text-foreground-light hover:text-foreground underline text-sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
|
||||
+77
-52
@@ -2,18 +2,17 @@ import { isEmpty } from 'lodash'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import SchemaEditor from 'components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Label_Shadcn_,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Separator,
|
||||
@@ -25,7 +24,6 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { CreateWrapperSheetProps } from './CreateWrapperSheet'
|
||||
import InputField from './InputField'
|
||||
import { makeValidateRequired } from './Wrappers.utils'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
const FORM_ID = 'create-wrapper-form'
|
||||
|
||||
@@ -68,12 +66,11 @@ export const CreateIcebergWrapperSheet = ({
|
||||
const org = useSelectedOrganization()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false)
|
||||
const [selectedTarget, setSelectedTarget] = useState<Target>('S3Tables')
|
||||
|
||||
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
|
||||
|
||||
const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({
|
||||
const { mutateAsync: createFDW, isLoading: isCreatingWrapper } = useFDWCreateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
|
||||
onClose()
|
||||
@@ -100,8 +97,10 @@ export const CreateIcebergWrapperSheet = ({
|
||||
}
|
||||
}, [wrapperMetaOriginal, selectedTarget])
|
||||
|
||||
// prefetch schemas to make sure the schema selector is populated
|
||||
useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString })
|
||||
const { data: schemas } = useSchemasQuery({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
wrapper_name: '',
|
||||
@@ -113,41 +112,72 @@ export const CreateIcebergWrapperSheet = ({
|
||||
),
|
||||
}
|
||||
|
||||
const { mutateAsync: createSchema, isLoading: isCreatingSchema } = useSchemaCreateMutation()
|
||||
|
||||
const onSubmit = async (values: any) => {
|
||||
const validate = makeValidateRequired(wrapperMeta.server.options)
|
||||
const errors: any = validate(values)
|
||||
|
||||
if (values.source_schema.length === 0) {
|
||||
errors.source_schema = 'Please provide a source schema'
|
||||
errors.source_schema = 'Please provide a namespace name'
|
||||
}
|
||||
if (values.wrapper_name.length === 0) {
|
||||
errors.wrapper_name = 'Please provide a name for your wrapper'
|
||||
}
|
||||
if (!isEmpty(errors)) return setFormErrors(errors)
|
||||
|
||||
createFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapperMeta,
|
||||
formState: { ...values, server_name: `${values.wrapper_name}_server` },
|
||||
mode: 'schema',
|
||||
tables: [],
|
||||
sourceSchema: values.source_schema,
|
||||
targetSchema: values.target_schema,
|
||||
})
|
||||
if (values.target_schema.length === 0) {
|
||||
errors.target_schema = 'Please provide an unique target schema'
|
||||
}
|
||||
const foundSchema = schemas?.find((s) => s.name === values.target_schema)
|
||||
if (foundSchema) {
|
||||
errors.target_schema = 'This schema already exists. Please specify a unique schema name.'
|
||||
}
|
||||
|
||||
sendEvent({
|
||||
action: 'foreign_data_wrapper_created',
|
||||
properties: {
|
||||
wrapperType: wrapperMeta.label,
|
||||
},
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
setFormErrors(errors)
|
||||
if (!isEmpty(errors)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createSchema({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
name: values.target_schema,
|
||||
})
|
||||
|
||||
await createFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapperMeta,
|
||||
formState: {
|
||||
...values,
|
||||
server_name: `${values.wrapper_name}_server`,
|
||||
supabase_target_schema: values.target_schema,
|
||||
},
|
||||
mode: 'schema',
|
||||
tables: [],
|
||||
sourceSchema: values.source_schema,
|
||||
targetSchema: values.target_schema,
|
||||
})
|
||||
|
||||
sendEvent({
|
||||
action: 'foreign_data_wrapper_created',
|
||||
properties: {
|
||||
wrapperType: wrapperMeta.label,
|
||||
},
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
// The error will be handled by the mutation onError callback (toast.error)
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = isCreatingWrapper || isCreatingSchema
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full" tabIndex={-1}>
|
||||
@@ -272,7 +302,8 @@ export const CreateIcebergWrapperSheet = ({
|
||||
<FormSectionLabel>
|
||||
<p>Foreign Schema</p>
|
||||
<p className="text-foreground-light mt-2 w-[90%]">
|
||||
All wrapper tables will be created in the specified target schema.
|
||||
You can query your data from the foreign tables in the specified schema
|
||||
after the wrapper is created.
|
||||
</p>
|
||||
</FormSectionLabel>
|
||||
}
|
||||
@@ -292,18 +323,21 @@ export const CreateIcebergWrapperSheet = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label_Shadcn_ className="text-foreground-light">
|
||||
Target Schema
|
||||
</Label_Shadcn_>
|
||||
<SchemaSelector
|
||||
portal={false}
|
||||
size="small"
|
||||
selectedSchemaName={values.target_schema}
|
||||
onSelectSchema={(schema) => setFieldValue('target_schema', schema)}
|
||||
onSelectCreateSchema={() => setCreateSchemaSheetOpen(true)}
|
||||
<InputField
|
||||
key="target_schema"
|
||||
option={{
|
||||
name: 'target_schema',
|
||||
label: 'Specify a new schema to create all wrapper tables in',
|
||||
required: true,
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
}}
|
||||
loading={false}
|
||||
error={formErrors['target_schema']}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Be careful not to use an API exposed schema.
|
||||
A new schema will be created. For security purposes, the wrapper tables
|
||||
from the foreign schema cannot be created within an existing schema
|
||||
</p>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
@@ -316,7 +350,7 @@ export const CreateIcebergWrapperSheet = ({
|
||||
type="default"
|
||||
htmlType="button"
|
||||
onClick={onClosePanel}
|
||||
disabled={isCreating}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -325,20 +359,11 @@ export const CreateIcebergWrapperSheet = ({
|
||||
type="primary"
|
||||
form={FORM_ID}
|
||||
htmlType="submit"
|
||||
disabled={isCreating}
|
||||
loading={isCreating}
|
||||
loading={isLoading}
|
||||
>
|
||||
Create wrapper
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
<SchemaEditor
|
||||
visible={createSchemaSheetOpen}
|
||||
closePanel={() => setCreateSchemaSheetOpen(false)}
|
||||
onSuccess={(schema) => {
|
||||
setFieldValue('target_schema', schema)
|
||||
setCreateSchemaSheetOpen(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -4,19 +4,18 @@ import { Edit, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import SchemaEditor from 'components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
|
||||
import { invalidateSchemasQuery, useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Label_Shadcn_,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Separator,
|
||||
@@ -30,7 +29,6 @@ import InputField from './InputField'
|
||||
import { WrapperMeta } from './Wrappers.types'
|
||||
import { makeValidateRequired } from './Wrappers.utils'
|
||||
import WrapperTableEditor from './WrapperTableEditor'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
export interface CreateWrapperSheetProps {
|
||||
isClosing: boolean
|
||||
@@ -48,6 +46,7 @@ export const CreateWrapperSheet = ({
|
||||
onClose,
|
||||
}: CreateWrapperSheetProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const org = useSelectedOrganization()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
@@ -73,7 +72,7 @@ export const CreateWrapperSheet = ({
|
||||
|
||||
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
|
||||
|
||||
const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({
|
||||
const { mutateAsync: createFDW, isLoading: isCreatingWrapper } = useFDWCreateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
|
||||
setNewTables([])
|
||||
@@ -85,8 +84,10 @@ export const CreateWrapperSheet = ({
|
||||
},
|
||||
})
|
||||
|
||||
// prefetch schemas to make sure the schema selector is populated
|
||||
useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString })
|
||||
const { data: schemas } = useSchemasQuery({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
wrapper_name: '',
|
||||
@@ -98,6 +99,8 @@ export const CreateWrapperSheet = ({
|
||||
),
|
||||
}
|
||||
|
||||
const { mutateAsync: createSchema, isLoading: isCreatingSchema } = useSchemaCreateMutation()
|
||||
|
||||
const onUpdateTable = (values: any) => {
|
||||
setNewTables((prev) => {
|
||||
// if the new values have tableIndex, we are editing an existing table
|
||||
@@ -121,37 +124,70 @@ export const CreateWrapperSheet = ({
|
||||
if (values.wrapper_name.length === 0) {
|
||||
errors.wrapper_name = 'Please provide a name for your wrapper'
|
||||
}
|
||||
if (selectedMode === 'tables' && newTables.length === 0) {
|
||||
errors.tables = 'Please add at least one table'
|
||||
}
|
||||
if (selectedMode === 'schema' && values.source_schema.length === 0) {
|
||||
errors.source_schema = 'Please provide a source schema'
|
||||
}
|
||||
if (!isEmpty(errors)) return setFormErrors(errors)
|
||||
|
||||
createFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapperMeta,
|
||||
formState: { ...values, server_name: `${values.wrapper_name}_server` },
|
||||
mode: selectedMode,
|
||||
tables: newTables,
|
||||
sourceSchema: values.source_schema,
|
||||
targetSchema: values.target_schema,
|
||||
})
|
||||
if (selectedMode === 'tables') {
|
||||
if (newTables.length === 0) {
|
||||
errors.tables = 'Please provide at least one table'
|
||||
}
|
||||
}
|
||||
if (selectedMode === 'schema') {
|
||||
if (values.source_schema.length === 0) {
|
||||
errors.source_schema = 'Please provide a source schema'
|
||||
}
|
||||
if (values.target_schema.length === 0) {
|
||||
errors.target_schema = 'Please provide an unique target schema'
|
||||
}
|
||||
const foundSchema = schemas?.find((s) => s.name === values.target_schema)
|
||||
if (foundSchema) {
|
||||
errors.target_schema = 'This schema already exists. Please specify a unique schema name.'
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent({
|
||||
action: 'foreign_data_wrapper_created',
|
||||
properties: {
|
||||
wrapperType: wrapperMeta.label,
|
||||
},
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
setFormErrors(errors)
|
||||
if (!isEmpty(errors)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createSchema({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
name: values.target_schema,
|
||||
})
|
||||
|
||||
await createFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapperMeta,
|
||||
formState: {
|
||||
...values,
|
||||
server_name: `${values.wrapper_name}_server`,
|
||||
supabase_target_schema: selectedMode === 'schema' ? values.target_schema : undefined,
|
||||
},
|
||||
mode: selectedMode,
|
||||
tables: newTables,
|
||||
sourceSchema: values.source_schema,
|
||||
targetSchema: values.target_schema,
|
||||
})
|
||||
|
||||
sendEvent({
|
||||
action: 'foreign_data_wrapper_created',
|
||||
properties: {
|
||||
wrapperType: wrapperMeta.label,
|
||||
},
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
// The error will be handled by the mutation onError callback (toast.error)
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = isCreatingWrapper || isCreatingSchema
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full" tabIndex={-1}>
|
||||
@@ -161,7 +197,7 @@ export const CreateWrapperSheet = ({
|
||||
onSubmit={onSubmit}
|
||||
className="flex-grow flex flex-col h-full"
|
||||
>
|
||||
{({ handleReset, values, initialValues, setFieldValue }: any) => {
|
||||
{({ values, initialValues, setFieldValue }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
const onClosePanel = () => {
|
||||
@@ -236,7 +272,7 @@ export const CreateWrapperSheet = ({
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Define tables where the wrapper data will be shown.
|
||||
Create foreign tables to query data from {wrapperMeta.label}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,7 +297,8 @@ export const CreateWrapperSheet = ({
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Specify schema in which the wrapper will create tables.
|
||||
Create all foreign tables from {wrapperMeta.label} in a specified
|
||||
schema.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -366,38 +403,44 @@ export const CreateWrapperSheet = ({
|
||||
<FormSectionLabel>
|
||||
<p>Foreign Schema</p>
|
||||
<p className="text-foreground-light mt-2 w-[90%]">
|
||||
All wrapper tables will be created in the specified target schema.
|
||||
You can query your data from the foreign tables in the specified schema
|
||||
after the wrapper is created.
|
||||
</p>
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent loading={false}>
|
||||
{wrapperMeta.sourceSchemaOption && (
|
||||
<div>
|
||||
<InputField
|
||||
key="source_schema"
|
||||
option={wrapperMeta.sourceSchemaOption}
|
||||
loading={false}
|
||||
error={formErrors['source_schema']}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
{wrapperMeta.sourceSchemaOption.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{wrapperMeta.sourceSchemaOption &&
|
||||
!wrapperMeta.sourceSchemaOption?.readOnly && (
|
||||
// Hide the field if the source schema is read-only
|
||||
<div>
|
||||
<InputField
|
||||
key="source_schema"
|
||||
option={wrapperMeta.sourceSchemaOption}
|
||||
loading={false}
|
||||
error={formErrors['source_schema']}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
{wrapperMeta.sourceSchemaOption.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label_Shadcn_ className="text-foreground-light">
|
||||
Target Schema
|
||||
</Label_Shadcn_>
|
||||
<SchemaSelector
|
||||
portal={false}
|
||||
size="small"
|
||||
selectedSchemaName={values.target_schema}
|
||||
onSelectSchema={(schema) => setFieldValue('target_schema', schema)}
|
||||
onSelectCreateSchema={() => setCreateSchemaSheetOpen(true)}
|
||||
<InputField
|
||||
key="target_schema"
|
||||
option={{
|
||||
name: 'target_schema',
|
||||
label: 'Specify a new schema to create all wrapper tables in',
|
||||
required: true,
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
}}
|
||||
loading={false}
|
||||
error={formErrors['target_schema']}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Be careful not to use an API exposed schema.
|
||||
A new schema will be created. For security purposes, the wrapper tables
|
||||
from the foreign schema cannot be created within an existing schema
|
||||
</p>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
@@ -411,7 +454,7 @@ export const CreateWrapperSheet = ({
|
||||
type="default"
|
||||
htmlType="button"
|
||||
onClick={onClosePanel}
|
||||
disabled={isCreating}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -420,20 +463,11 @@ export const CreateWrapperSheet = ({
|
||||
type="primary"
|
||||
form={FORM_ID}
|
||||
htmlType="submit"
|
||||
disabled={isCreating}
|
||||
loading={isCreating}
|
||||
loading={isLoading}
|
||||
>
|
||||
Create wrapper
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
<SchemaEditor
|
||||
visible={createSchemaSheetOpen}
|
||||
closePanel={() => setCreateSchemaSheetOpen(false)}
|
||||
onSuccess={(schema) => {
|
||||
setFieldValue('target_schema', schema)
|
||||
setCreateSchemaSheetOpen(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -107,37 +107,39 @@ const WrapperRow = ({ wrapper }: WrapperRowProps) => {
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="space-x-2 flex-nowrap">
|
||||
<ButtonTooltip
|
||||
disabled={!canManageWrappers}
|
||||
type="default"
|
||||
icon={<Edit strokeWidth={1.5} />}
|
||||
className="px-1.5"
|
||||
onClick={() => setEditWrapperShown(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageWrappers
|
||||
? 'You need additional permissions to edit wrappers'
|
||||
: 'Edit wrapper',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!canManageWrappers}
|
||||
icon={<Trash strokeWidth={1.5} />}
|
||||
className="px-1.5"
|
||||
onClick={() => setDeleteWrapperShown(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageWrappers
|
||||
? 'You need additional permissions to delete wrappers'
|
||||
: 'Delete wrapper',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TableCell className="flex-nowrap">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ButtonTooltip
|
||||
disabled={!canManageWrappers}
|
||||
type="default"
|
||||
icon={<Edit strokeWidth={1.5} />}
|
||||
className="px-1.5"
|
||||
onClick={() => setEditWrapperShown(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageWrappers
|
||||
? 'You need additional permissions to edit wrappers'
|
||||
: 'Edit wrapper',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!canManageWrappers}
|
||||
icon={<Trash strokeWidth={1.5} />}
|
||||
className="px-1.5"
|
||||
onClick={() => setDeleteWrapperShown(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageWrappers
|
||||
? 'You need additional permissions to delete wrappers'
|
||||
: 'Delete wrapper',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<Sheet open={editWrapperShown} onOpenChange={() => setIsClosingEditWrapper(true)}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet'
|
||||
import type { WrapperMeta } from './Wrappers.types'
|
||||
import type { ServerOption, WrapperMeta } from './Wrappers.types'
|
||||
|
||||
export const WRAPPER_HANDLERS = {
|
||||
STRIPE: 'stripe_fdw_handler',
|
||||
@@ -27,6 +27,16 @@ export const WRAPPER_HANDLERS = {
|
||||
ORB: 'wasm_fdw_handler',
|
||||
}
|
||||
|
||||
const SUPABASE_TARGET_SCHEMA_OPTION: ServerOption = {
|
||||
name: 'supabase_target_schema',
|
||||
label: 'Target Schema',
|
||||
required: false,
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
readOnly: true,
|
||||
hidden: true,
|
||||
}
|
||||
|
||||
export const WRAPPERS: WrapperMeta[] = [
|
||||
{
|
||||
name: 'stripe_wrapper',
|
||||
@@ -55,6 +65,7 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
},
|
||||
SUPABASE_TARGET_SCHEMA_OPTION,
|
||||
],
|
||||
},
|
||||
tables: [
|
||||
@@ -1775,6 +1786,7 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
},
|
||||
SUPABASE_TARGET_SCHEMA_OPTION,
|
||||
],
|
||||
},
|
||||
canTargetSchema: true,
|
||||
@@ -2224,12 +2236,13 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
},
|
||||
SUPABASE_TARGET_SCHEMA_OPTION,
|
||||
],
|
||||
},
|
||||
canTargetSchema: true,
|
||||
sourceSchemaOption: {
|
||||
name: 'source_schema',
|
||||
label: 'Source Schema',
|
||||
label: 'Namespace',
|
||||
description: 'It should match the namespace of the Iceberg catalog.',
|
||||
required: true,
|
||||
encrypted: false,
|
||||
|
||||
@@ -12,7 +12,7 @@ export const makeValidateRequired = (options: { name: string; required: boolean
|
||||
)
|
||||
const requiredArrayOptions = Array.from(requiredArrayOptionsSet)
|
||||
|
||||
return (values: any) => {
|
||||
return (values: Record<string, any>) => {
|
||||
const errors = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.flatMap(([key, value]) =>
|
||||
|
||||
+12
-5
@@ -20,15 +20,18 @@ import {
|
||||
useAsyncCheckProjectPermissions,
|
||||
useCheckPermissions,
|
||||
} from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { Button, Card, CardFooter, Form_Shadcn_ as Form } from 'ui'
|
||||
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
|
||||
import {
|
||||
BillingCustomerDataForm,
|
||||
type BillingCustomerDataFormValues,
|
||||
} from './BillingCustomerDataForm'
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
|
||||
|
||||
export const BillingCustomerData = () => {
|
||||
const { slug } = useParams()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
|
||||
const { isSuccess: isPermissionsLoaded, can: canReadBillingCustomerData } =
|
||||
useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.customer')
|
||||
@@ -51,9 +54,13 @@ export const BillingCustomerData = () => {
|
||||
isSuccess: loadedTaxId,
|
||||
} = useOrganizationTaxIdQuery({ slug })
|
||||
|
||||
const initialCustomerData = useMemo(
|
||||
const initialCustomerData = useMemo<Partial<BillingCustomerDataFormValues>>(
|
||||
() => ({
|
||||
...customerProfile?.address,
|
||||
city: customerProfile?.address?.city ?? undefined,
|
||||
country: customerProfile?.address?.country,
|
||||
line1: customerProfile?.address?.line1,
|
||||
line2: customerProfile?.address?.line2 ?? undefined,
|
||||
postal_code: customerProfile?.address?.postal_code ?? undefined,
|
||||
billing_name: customerProfile?.billing_name,
|
||||
tax_id_type: taxId?.type,
|
||||
tax_id_value: taxId?.value,
|
||||
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
|
||||
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
|
||||
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
Form_Shadcn_ as Form,
|
||||
Label_Shadcn_ as Label,
|
||||
} from 'ui'
|
||||
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
|
||||
|
||||
export const BillingCustomerDataExistingOrgDialog = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const { slug } = useSelectedOrganization() ?? {}
|
||||
|
||||
const canReadBillingCustomerData = useCheckPermissions(
|
||||
PermissionAction.BILLING_READ,
|
||||
'stripe.customer'
|
||||
)
|
||||
|
||||
const canUpdateBillingCustomerData = useCheckPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.customer'
|
||||
)
|
||||
|
||||
const {
|
||||
data: customerProfile,
|
||||
error,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
} = useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingCustomerData })
|
||||
|
||||
const {
|
||||
data: taxId,
|
||||
error: errorTaxId,
|
||||
isLoading: isLoadingTaxId,
|
||||
isSuccess: isSuccessTaxId,
|
||||
} = useOrganizationTaxIdQuery({ slug })
|
||||
|
||||
const initialCustomerData = useMemo(
|
||||
() => ({
|
||||
...customerProfile?.address,
|
||||
billing_name: customerProfile?.billing_name,
|
||||
tax_id_type: taxId?.type,
|
||||
tax_id_value: taxId?.value,
|
||||
tax_id_name: taxId
|
||||
? TAX_IDS.find(
|
||||
(option) => option.type === taxId.type && option.countryIso2 === taxId.country
|
||||
)?.name || ''
|
||||
: '',
|
||||
}),
|
||||
[customerProfile, taxId]
|
||||
)
|
||||
|
||||
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
|
||||
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
|
||||
|
||||
const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({
|
||||
initialCustomerData,
|
||||
onCustomerDataChange: async (data) => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await updateCustomerProfile({
|
||||
slug,
|
||||
address: data.address ?? undefined,
|
||||
billing_name: data.billing_name,
|
||||
})
|
||||
|
||||
await updateTaxId({ slug, taxId: data.tax_id })
|
||||
|
||||
toast.success('Successfully updated billing data')
|
||||
|
||||
setOpen(false)
|
||||
|
||||
setIsSubmitting(false)
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed updating billing data: ${error.message}`)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
handleReset()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const getAddressSummary = () => {
|
||||
if (!customerProfile?.address?.line1) return 'Optionally add a billing address'
|
||||
|
||||
const parts = [
|
||||
customerProfile?.billing_name,
|
||||
customerProfile?.address?.line1,
|
||||
customerProfile?.address?.city,
|
||||
customerProfile?.address?.state,
|
||||
customerProfile?.address?.country,
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
const isSubmitDisabled = !isDirty || !canUpdateBillingCustomerData || isSubmitting
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="billing-address-btn" className="text-foreground-light block mb-0">
|
||||
Billing Address / Tax Id
|
||||
</Label>
|
||||
{!canReadBillingCustomerData ? (
|
||||
<NoPermission resourceText="view this organization's billing address" />
|
||||
) : (
|
||||
<>
|
||||
{(isLoading || isLoadingTaxId) && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
</div>
|
||||
)}
|
||||
{(error || errorTaxId) && (
|
||||
<AlertError
|
||||
subject="Failed to retrieve organization customer profile"
|
||||
error={(error || errorTaxId) as any}
|
||||
/>
|
||||
)}
|
||||
{isSuccess && isSuccessTaxId && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-foreground">{getAddressSummary()}</p>
|
||||
<Button
|
||||
id="billing-address-btn"
|
||||
onClick={() => setOpen(true)}
|
||||
type="text"
|
||||
aria-label="Edit"
|
||||
size={'tiny'}
|
||||
className="w-8 h-8 p-0 text-foreground-light"
|
||||
disabled={!canUpdateBillingCustomerData}
|
||||
>
|
||||
<Pencil size={14} strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) setOpen(false)
|
||||
else setOpen(true)
|
||||
}}
|
||||
>
|
||||
<DialogContent size={'large'}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Billing Address & Tax Id</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will be reflected in every upcoming invoice, past invoices are not affected
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<BillingCustomerDataForm
|
||||
className="p-5"
|
||||
form={form}
|
||||
disabled={!canUpdateBillingCustomerData}
|
||||
/>
|
||||
<DialogFooter className="justify-end">
|
||||
{!canUpdateBillingCustomerData && (
|
||||
<span className="text-sm text-foreground-lighter">
|
||||
You need additional permissions to manage this organization's billing address
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="default" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+43
-35
@@ -2,7 +2,7 @@ import { Check, ChevronsUpDown, X } from 'lucide-react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -90,18 +90,24 @@ export const BillingCustomerDataForm = ({
|
||||
form.setValue('tax_id_value', '', { shouldDirty: true })
|
||||
}
|
||||
|
||||
const { tax_id_name } = form.watch()
|
||||
const { tax_id_name, country } = form.watch()
|
||||
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
|
||||
|
||||
const availableTaxIds = useMemo(() => {
|
||||
return TAX_IDS.filter((taxId) => !country || taxId.countryIso2 === country).sort((a, b) =>
|
||||
a.country.localeCompare(b.country)
|
||||
)
|
||||
}, [country])
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col space-y-4', className)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="billing_name"
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormItemLayout hideMessage>
|
||||
<FormItemLayout hideMessage label="Name">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Name" disabled={disabled} />
|
||||
<Input {...field} disabled={disabled} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItemLayout>
|
||||
@@ -112,9 +118,9 @@ export const BillingCustomerDataForm = ({
|
||||
control={form.control}
|
||||
name="line1"
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormItemLayout hideMessage>
|
||||
<FormItemLayout hideMessage label="Address line 1">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Address line 1" disabled={disabled} />
|
||||
<Input {...field} placeholder="123 Main Street" disabled={disabled} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItemLayout>
|
||||
@@ -125,9 +131,13 @@ export const BillingCustomerDataForm = ({
|
||||
control={form.control}
|
||||
name="line2"
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormItemLayout hideMessage>
|
||||
<FormItemLayout hideMessage label="Address line 2 (optional)">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Address line 2 (Optional)" disabled={disabled} />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Apartment, suite, unit, building, floor, etc."
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItemLayout>
|
||||
@@ -139,7 +149,7 @@ export const BillingCustomerDataForm = ({
|
||||
control={form.control}
|
||||
name="country"
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormItemLayout hideMessage>
|
||||
<FormItemLayout hideMessage label="Country">
|
||||
<Popover open={showCountriesPopover} onOpenChange={setShowCountriesPopover}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
@@ -207,9 +217,9 @@ export const BillingCustomerDataForm = ({
|
||||
control={form.control}
|
||||
name="postal_code"
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormItemLayout hideMessage>
|
||||
<FormItemLayout hideMessage label="Postal code">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Postal code" disabled={disabled} />
|
||||
<Input {...field} placeholder="12345" disabled={disabled} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItemLayout>
|
||||
@@ -222,9 +232,9 @@ export const BillingCustomerDataForm = ({
|
||||
control={form.control}
|
||||
name="city"
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormItemLayout hideMessage>
|
||||
<FormItemLayout hideMessage label="City">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="City" disabled={disabled} />
|
||||
<Input {...field} disabled={disabled} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItemLayout>
|
||||
@@ -234,9 +244,9 @@ export const BillingCustomerDataForm = ({
|
||||
control={form.control}
|
||||
name="state"
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormItemLayout hideMessage>
|
||||
<FormItemLayout hideMessage label="State / Province">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="State / Province" disabled={disabled} />
|
||||
<Input {...field} disabled={disabled} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItemLayout>
|
||||
@@ -281,26 +291,24 @@ export const BillingCustomerDataForm = ({
|
||||
<CommandList>
|
||||
<CommandEmpty>No tax ID found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map(
|
||||
(option) => (
|
||||
<CommandItem
|
||||
key={option.name}
|
||||
value={`${option.country} - ${option.name}`}
|
||||
onSelect={() => {
|
||||
onSelectTaxIdType(option.name)
|
||||
setShowTaxIDsPopover(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{option.country} - {option.name}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
{availableTaxIds.map((option) => (
|
||||
<CommandItem
|
||||
key={option.name}
|
||||
value={`${option.country} - ${option.name}`}
|
||||
onSelect={() => {
|
||||
onSelectTaxIdType(option.name)
|
||||
setShowTaxIDsPopover(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{option.country} - {option.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
import { FocusTrap } from '@headlessui/react'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form_Shadcn_ as Form,
|
||||
} from 'ui'
|
||||
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
|
||||
import { FormCustomerData, useBillingCustomerDataForm } from './useBillingCustomerDataForm'
|
||||
|
||||
interface BillingCustomerDataNewOrgDialogProps {
|
||||
onCustomerDataChange: (data: FormCustomerData) => void
|
||||
}
|
||||
|
||||
export const BillingCustomerDataNewOrgDialog = ({
|
||||
onCustomerDataChange,
|
||||
}: BillingCustomerDataNewOrgDialogProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({
|
||||
onCustomerDataChange,
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
handleReset()
|
||||
handleDialogClose()
|
||||
}
|
||||
|
||||
const getAddressSummary = () => {
|
||||
const { line1, city, state, country, billing_name } = form.getValues()
|
||||
|
||||
if (!line1) return 'Optionally add a billing address'
|
||||
|
||||
const parts = [billing_name, line1, city, state, country].filter(Boolean)
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
const isSubmitDisabled = !isDirty
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-foreground-light" htmlFor="billing-address-btn">
|
||||
{getAddressSummary()}
|
||||
</label>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) handleDialogClose()
|
||||
else setOpen(true)
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
id="billing-address-btn"
|
||||
onClick={() => setOpen(true)}
|
||||
type="text"
|
||||
aria-label="Edit"
|
||||
size={'tiny'}
|
||||
className="w-8 h-8 p-0 text-foreground-light"
|
||||
>
|
||||
<Pencil size={14} strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent size="large">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Billing Address & Tax Id</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
{/*
|
||||
[Joshen] There's something odd going on with using Dialog and RHF FormField here
|
||||
where the focus keeps going to the Dialog when tabbing across the input fields, hence the FocusTrap
|
||||
What's weirder is that once you've cycled the focus through all the inputs at least once, the focus
|
||||
then no longer goes to the Dialog thereafter
|
||||
FWIW it's likely happening across the dashboard whereever we're using Dialog and FormField
|
||||
*/}
|
||||
<FocusTrap>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="new-org-billing-data-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
form.handleSubmit(handleSubmit)(e)
|
||||
|
||||
// Only close the dialog if the form is valid
|
||||
if (!Object.keys(form.formState.errors).length) {
|
||||
handleDialogClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BillingCustomerDataForm className="p-5" form={form} />
|
||||
<DialogFooter className="justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="default" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" disabled={isSubmitDisabled}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</FocusTrap>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-5
@@ -1,8 +1,3 @@
|
||||
// List of available Tax IDs as reflected in Stripe's web portal
|
||||
// This was manually ported over so there may be a chance of mistakes
|
||||
// Last updated as of 29th March 2022.
|
||||
// The code may not necessarily match with the name (ref SE_VAT)
|
||||
// https://stripe.com/docs/api/customer_tax_ids/create
|
||||
export interface TaxId {
|
||||
name: string
|
||||
type: string
|
||||
|
||||
+2
-1
@@ -6,9 +6,10 @@ import { BillingCustomerDataFormValues, BillingCustomerDataSchema } from './Bill
|
||||
import { TAX_IDS } from './TaxID.constants'
|
||||
import { sanitizeTaxIdValue } from './TaxID.utils'
|
||||
import { components } from 'api-types'
|
||||
import type { CustomerAddress } from 'data/organizations/types'
|
||||
|
||||
export type FormCustomerData = {
|
||||
address: components['schemas']['CustomerResponse']['address'] | undefined
|
||||
address: CustomerAddress | undefined
|
||||
billing_name: string
|
||||
tax_id: components['schemas']['TaxIdResponse']['tax_id'] | null
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertCircle, Info } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
@@ -17,7 +17,6 @@ import { useOrganizationCreditTopUpMutation } from 'data/organizations/organizat
|
||||
import { subscriptionKeys } from 'data/subscriptions/keys'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
|
||||
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
|
||||
import {
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
@@ -40,6 +39,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import PaymentMethodSelection from './Subscription/PaymentMethodSelection'
|
||||
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
|
||||
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
|
||||
import type { PaymentMethodElementRef } from './PaymentMethods/NewPaymentMethodElement'
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
||||
|
||||
@@ -48,8 +48,8 @@ const FORM_ID = 'credit-top-up'
|
||||
const FormSchema = z.object({
|
||||
amount: z.coerce
|
||||
.number()
|
||||
.gte(100, 'Amount must be between $100 - $999.')
|
||||
.lte(999, 'Amount must be between $100 - $999.')
|
||||
.gte(100, 'Amount must be between $100 - $2000.')
|
||||
.lte(2000, 'Amount must be between $100 - $2000.')
|
||||
.int('Amount must be a whole number.'),
|
||||
paymentMethod: z.string(),
|
||||
})
|
||||
@@ -59,6 +59,9 @@ type CreditTopUpForm = z.infer<typeof FormSchema>
|
||||
export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const queryClient = useQueryClient()
|
||||
const paymentMethodSelectionRef = useRef<{
|
||||
createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
|
||||
}>(null)
|
||||
|
||||
const canTopUpCredits = useCheckPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
@@ -82,7 +85,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
|
||||
const [topUpModalVisible, setTopUpModalVisible] = useState(false)
|
||||
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
|
||||
const captchaLoaded = useIsHCaptchaLoaded()
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
|
||||
|
||||
@@ -96,7 +98,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
}
|
||||
|
||||
const initHcaptcha = async () => {
|
||||
if (topUpModalVisible && captchaRef && captchaLoaded) {
|
||||
if (topUpModalVisible && captchaRef) {
|
||||
let token = captchaToken
|
||||
|
||||
try {
|
||||
@@ -116,7 +118,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
|
||||
useEffect(() => {
|
||||
initHcaptcha()
|
||||
}, [topUpModalVisible, captchaRef, captchaLoaded])
|
||||
}, [topUpModalVisible, captchaRef])
|
||||
|
||||
const [paymentIntentSecret, setPaymentIntentSecret] = useState('')
|
||||
const [paymentIntentConfirmation, setPaymentIntentConfirmation] = useState<PaymentIntentResult>()
|
||||
@@ -126,12 +128,20 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
|
||||
const token = await initHcaptcha()
|
||||
|
||||
const paymentMethodResult = await paymentMethodSelectionRef.current?.createPaymentMethod()
|
||||
if (!paymentMethodResult) {
|
||||
return
|
||||
}
|
||||
|
||||
await topUpCredits(
|
||||
{
|
||||
slug,
|
||||
amount,
|
||||
payment_method_id: paymentMethod,
|
||||
payment_method_id: paymentMethodResult.paymentMethod.id,
|
||||
hcaptchaToken: token,
|
||||
address: paymentMethodResult.address,
|
||||
tax_id: paymentMethodResult.taxId ?? undefined,
|
||||
billing_name: paymentMethodResult.customerName,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
@@ -225,10 +235,21 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
/>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Top Up Credits</DialogTitle>
|
||||
<DialogDescription>
|
||||
On successful payment, an invoice will be issued and you'll be granted credits.
|
||||
Credits will be applied to outstanding and future invoices and are not refundable. The
|
||||
topped up credits do not expire.
|
||||
<DialogDescription className="space-y-2">
|
||||
<p className="prose text-sm">
|
||||
On successful payment, an invoice will be issued and you'll be granted credits.
|
||||
Credits will be applied to outstanding and future invoices and are not refundable.
|
||||
The topped up credits do not expire.
|
||||
</p>
|
||||
<p className="prose text-sm">
|
||||
For larger discounted credit packages, please{' '}
|
||||
<Link
|
||||
href={`/support/new?slug=${slug}&subject=${encodeURIComponent('I would like to inquire about larger credit packages')}&category=${SupportCategories.SALES_ENQUIRY}`}
|
||||
target="_blank"
|
||||
>
|
||||
reach out.
|
||||
</Link>
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -252,7 +273,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
name="paymentMethod"
|
||||
render={() => (
|
||||
<PaymentMethodSelection
|
||||
createPaymentMethodInline={false}
|
||||
ref={paymentMethodSelectionRef}
|
||||
onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)}
|
||||
selectedPaymentMethod={form.getValues('paymentMethod')}
|
||||
readOnly={executingTopUp || paymentConfirmationLoading}
|
||||
@@ -260,16 +281,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="prose text-sm">
|
||||
For larger discounted credit packages, please{' '}
|
||||
<Link
|
||||
href={`/support/new?slug=${slug}&subject=${encodeURIComponent('I would like to inquire about larger credit packages')}&category=${SupportCategories.SALES_ENQUIRY}`}
|
||||
target="_blank"
|
||||
>
|
||||
reach out.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{paymentIntentConfirmation && paymentIntentConfirmation.error && (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
||||
+321
-7
@@ -4,26 +4,148 @@
|
||||
* If Elements is on a higher level, we risk losing all form state in case a payment fails.
|
||||
*/
|
||||
|
||||
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
|
||||
import { forwardRef, useImperativeHandle } from 'react'
|
||||
import { AddressElement, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
|
||||
import {
|
||||
StripeAddressElementChangeEvent,
|
||||
StripeAddressElementOptions,
|
||||
type SetupIntent,
|
||||
} from '@stripe/stripe-js'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Checkbox_Shadcn_,
|
||||
cn,
|
||||
Command_Shadcn_ as Command,
|
||||
CommandEmpty_Shadcn_ as CommandEmpty,
|
||||
CommandGroup_Shadcn_ as CommandGroup,
|
||||
CommandInput_Shadcn_ as CommandInput,
|
||||
CommandItem_Shadcn_ as CommandItem,
|
||||
CommandList_Shadcn_ as CommandList,
|
||||
FormControl_Shadcn_ as FormControl,
|
||||
FormField_Shadcn_ as FormField,
|
||||
FormItem_Shadcn_,
|
||||
FormMessage_Shadcn_ as FormMessage,
|
||||
Input_Shadcn_ as Input,
|
||||
Popover_Shadcn_ as Popover,
|
||||
PopoverContent_Shadcn_ as PopoverContent,
|
||||
PopoverTrigger_Shadcn_ as PopoverTrigger,
|
||||
} from 'ui'
|
||||
import { TAX_IDS, type TaxId } from '../BillingCustomerData/TaxID.constants'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Form } from '@ui/components/shadcn/ui/form'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { getURL } from 'lib/helpers'
|
||||
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
|
||||
import type { PaymentMethod } from '@stripe/stripe-js'
|
||||
|
||||
export const BillingCustomerDataSchema = z.object({
|
||||
tax_id_type: z.string(),
|
||||
tax_id_value: z.string().min(2, {
|
||||
message: 'Tax ID needs to be set.',
|
||||
}),
|
||||
tax_id_name: z.string(),
|
||||
})
|
||||
|
||||
type BillingCustomerDataFormValues = z.infer<typeof BillingCustomerDataSchema>
|
||||
|
||||
export type PaymentMethodElementRef = {
|
||||
confirmSetup: () => Promise<
|
||||
| {
|
||||
setupIntent: SetupIntent
|
||||
address: CustomerAddress
|
||||
customerName: string
|
||||
taxId: CustomerTaxId | null
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
createPaymentMethod: () => Promise<
|
||||
| {
|
||||
paymentMethod: PaymentMethod
|
||||
address: CustomerAddress | null
|
||||
customerName: string | null
|
||||
taxId: CustomerTaxId | null
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
}
|
||||
|
||||
const NewPaymentMethodElement = forwardRef(
|
||||
(
|
||||
{
|
||||
email,
|
||||
readOnly,
|
||||
currentAddress,
|
||||
currentTaxId,
|
||||
customerName,
|
||||
}: {
|
||||
email?: string | null | undefined
|
||||
readOnly: boolean
|
||||
currentAddress?: CustomerAddress | null
|
||||
currentTaxId?: CustomerTaxId | null
|
||||
customerName?: string | undefined
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
|
||||
const createPaymentMethod = async () => {
|
||||
const form = useForm<BillingCustomerDataFormValues>({
|
||||
resolver: zodResolver(BillingCustomerDataSchema),
|
||||
defaultValues: {
|
||||
tax_id_name: currentTaxId
|
||||
? TAX_IDS.find(
|
||||
(option) =>
|
||||
option.type === currentTaxId.type && option.countryIso2 === currentTaxId.country
|
||||
)?.name || ''
|
||||
: '',
|
||||
tax_id_type: currentTaxId ? currentTaxId.type : '',
|
||||
tax_id_value: currentTaxId ? currentTaxId.value : '',
|
||||
},
|
||||
})
|
||||
|
||||
// To avoid rendering the business checkbox prematurely and causing weird layout shifts, we wait until the address element is fully loaded
|
||||
const [fullyLoaded, setFullyLoaded] = useState(false)
|
||||
|
||||
const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false)
|
||||
|
||||
const onSelectTaxIdType = (name: string) => {
|
||||
const selectedTaxIdOption = TAX_IDS.find((option) => option.name === name)
|
||||
if (!selectedTaxIdOption) return
|
||||
form.setValue('tax_id_type', selectedTaxIdOption.type)
|
||||
form.setValue('tax_id_value', '')
|
||||
form.setValue('tax_id_name', name)
|
||||
}
|
||||
|
||||
const { tax_id_name } = form.watch()
|
||||
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
|
||||
|
||||
const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null)
|
||||
const [stripeAddress, setStripeAddress] = useState<
|
||||
StripeAddressElementChangeEvent['value'] | undefined
|
||||
>(undefined)
|
||||
|
||||
const availableTaxIds = useMemo(() => {
|
||||
const country = stripeAddress?.address.country || null
|
||||
|
||||
return TAX_IDS.filter((taxId) => country == null || taxId.countryIso2 === country).sort(
|
||||
(a, b) => a.country.localeCompare(b.country)
|
||||
)
|
||||
}, [stripeAddress])
|
||||
|
||||
const createPaymentMethod = async (): ReturnType<
|
||||
PaymentMethodElementRef['createPaymentMethod']
|
||||
> => {
|
||||
if (!stripe || !elements) return
|
||||
await form.trigger()
|
||||
|
||||
if (purchasingAsBusiness && availableTaxIds.length > 0 && !form.getValues('tax_id_value')) {
|
||||
return
|
||||
}
|
||||
|
||||
await elements.submit()
|
||||
|
||||
// To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment
|
||||
@@ -34,17 +156,209 @@ const NewPaymentMethodElement = forwardRef(
|
||||
toast.error(error?.message ?? ' Failed to process card details')
|
||||
return
|
||||
}
|
||||
return paymentMethod
|
||||
|
||||
const addressElement = await elements.getElement('address')!.getValue()
|
||||
return {
|
||||
paymentMethod,
|
||||
address: {
|
||||
...addressElement.value.address,
|
||||
line2: addressElement.value.address.line2 || undefined,
|
||||
},
|
||||
customerName: addressElement.value.name,
|
||||
taxId: getConfiguredTaxId(),
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredTaxId(): CustomerTaxId | null {
|
||||
return purchasingAsBusiness && selectedTaxId
|
||||
? {
|
||||
country: selectedTaxId.countryIso2,
|
||||
type: selectedTaxId.type,
|
||||
value: form.getValues('tax_id_value'),
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const confirmSetup = async (): ReturnType<PaymentMethodElementRef['confirmSetup']> => {
|
||||
if (!stripe || !elements) return
|
||||
|
||||
await elements.submit()
|
||||
|
||||
const { error, setupIntent } = await stripe.confirmSetup({
|
||||
elements,
|
||||
redirect: 'if_required',
|
||||
confirmParams: { return_url: `${getURL()}/org/_/billing` },
|
||||
})
|
||||
|
||||
if (error || setupIntent == null) {
|
||||
toast.error(error?.message ?? ' Failed to process card details')
|
||||
return
|
||||
}
|
||||
|
||||
const addressElement = await elements.getElement('address')!.getValue()
|
||||
return {
|
||||
setupIntent,
|
||||
address: {
|
||||
...addressElement.value.address,
|
||||
line2: addressElement.value.address.line2 || undefined,
|
||||
},
|
||||
customerName: addressElement.value.name,
|
||||
taxId: getConfiguredTaxId(),
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
createPaymentMethod,
|
||||
confirmSetup,
|
||||
}))
|
||||
|
||||
const addressOptions: StripeAddressElementOptions = useMemo(
|
||||
() => ({
|
||||
mode: 'billing',
|
||||
autocomplete: {
|
||||
apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!,
|
||||
mode: 'google_maps_api',
|
||||
},
|
||||
display: { name: purchasingAsBusiness ? 'organization' : 'full' },
|
||||
defaultValues: {
|
||||
address: currentAddress ?? undefined,
|
||||
name: customerName,
|
||||
},
|
||||
}),
|
||||
[purchasingAsBusiness]
|
||||
)
|
||||
|
||||
// Preselect tax id if there is no more than 2 available tax ids (even if there are two options, first one in the list is likely to be it)
|
||||
useEffect(() => {
|
||||
if (availableTaxIds.length && stripeAddress?.address.country && !currentTaxId) {
|
||||
const taxIdOption = availableTaxIds[0]
|
||||
form.setValue('tax_id_type', taxIdOption.type)
|
||||
form.setValue('tax_id_value', '')
|
||||
form.setValue('tax_id_name', taxIdOption.name)
|
||||
}
|
||||
}, [availableTaxIds, stripeAddress])
|
||||
|
||||
return (
|
||||
<PaymentElement
|
||||
options={{ defaultValues: { billingDetails: { email: email ?? undefined } }, readOnly }}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground-light mb-2">
|
||||
Please ensure CVC and postal codes match what is on file for your card.
|
||||
</p>
|
||||
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: 'tabs',
|
||||
defaultValues: { billingDetails: { email: email ?? undefined } },
|
||||
readOnly,
|
||||
}}
|
||||
/>
|
||||
|
||||
{fullyLoaded && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox_Shadcn_
|
||||
id="business"
|
||||
checked={purchasingAsBusiness}
|
||||
onCheckedChange={() => setPurchasingAsBusiness(!purchasingAsBusiness)}
|
||||
/>
|
||||
<label htmlFor="business" className="text-foreground-light text-sm leading-none">
|
||||
I'm purchasing as a business
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddressElement
|
||||
options={addressOptions}
|
||||
// Force reload after changing purchasingAsBusiness setting, it seems like the element does not reload otherwise
|
||||
key={`address-elements-${purchasingAsBusiness}`}
|
||||
onChange={(evt) => setStripeAddress(evt.value)}
|
||||
onReady={() => setFullyLoaded(true)}
|
||||
/>
|
||||
|
||||
{purchasingAsBusiness && availableTaxIds.length > 0 && (
|
||||
<Form {...form}>
|
||||
<div className="grid grid-cols-2 gap-x-2 w-full">
|
||||
<FormField
|
||||
name="tax_id_name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout hideMessage layout="vertical">
|
||||
<Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
type="default"
|
||||
role="combobox"
|
||||
size="medium"
|
||||
className={cn(
|
||||
'w-full justify-between h-[34px]',
|
||||
!selectedTaxId && 'text-muted'
|
||||
)}
|
||||
iconRight={
|
||||
<ChevronsUpDown
|
||||
className="ml-2 h-4 w-4 shrink-0 opacity-50"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selectedTaxId
|
||||
? `${selectedTaxId.country} - ${selectedTaxId.name}`
|
||||
: 'Select tax ID'}
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent sameWidthAsTrigger className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search tax ID..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No tax ID found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTaxIds.map((option) => (
|
||||
<CommandItem
|
||||
key={option.name}
|
||||
value={`${option.country} - ${option.name}`}
|
||||
onSelect={() => {
|
||||
onSelectTaxIdType(option.name)
|
||||
setShowTaxIDsPopover(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedTaxId?.name === option.name
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{option.country} - {option.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedTaxId && (
|
||||
<FormField
|
||||
name="tax_id_value"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={selectedTaxId?.placeholder} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
-1
@@ -176,7 +176,6 @@ const PaymentMethods = () => {
|
||||
setShowAddPaymentMethodModal(false)
|
||||
toast.success('Successfully added new payment method')
|
||||
}}
|
||||
showSetDefaultCheckbox={true}
|
||||
/>
|
||||
|
||||
<ChangePaymentMethodModal
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { PricingInformation } from 'shared-data'
|
||||
import { pickFeatures } from 'shared-data/plans'
|
||||
import { Button, cn } from 'ui'
|
||||
@@ -13,7 +13,7 @@ export interface EnterpriseCardProps {
|
||||
}
|
||||
|
||||
export const EnterpriseCard = ({ plan, isCurrentPlan, billingPartner }: EnterpriseCardProps) => {
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const orgSlug = selectedOrganization?.slug
|
||||
|
||||
const features = pickFeatures(plan, billingPartner)
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { Button, Modal } from 'ui'
|
||||
|
||||
export interface MembersExceedLimitModalProps {
|
||||
@@ -8,7 +8,7 @@ export interface MembersExceedLimitModalProps {
|
||||
}
|
||||
|
||||
const MembersExceedLimitModal = ({ visible, onClose }: MembersExceedLimitModalProps) => {
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const slug = selectedOrganization?.slug
|
||||
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
|
||||
{ slug },
|
||||
|
||||
+99
-103
@@ -1,4 +1,3 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
@@ -9,26 +8,30 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
useSelectedOrganization,
|
||||
useSelectedOrganizationQuery,
|
||||
} from 'hooks/misc/useSelectedOrganization'
|
||||
import { BASE_PATH, STRIPE_PUBLIC_KEY } from 'lib/constants'
|
||||
import { getURL } from 'lib/helpers'
|
||||
import { AlertCircle, CreditCard, Loader, Plus } from 'lucide-react'
|
||||
import { Listbox } from 'ui'
|
||||
import { Loader, Plus } from 'lucide-react'
|
||||
import { Checkbox_Shadcn_, Listbox } from 'ui'
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
|
||||
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
|
||||
import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
|
||||
import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
|
||||
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Elements } from '@stripe/react-stripe-js'
|
||||
import { NewPaymentMethodElement } from '../PaymentMethods/NewPaymentMethodElement'
|
||||
import {
|
||||
NewPaymentMethodElement,
|
||||
type PaymentMethodElementRef,
|
||||
} from '../PaymentMethods/NewPaymentMethodElement'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
|
||||
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
|
||||
import { useParams } from 'common'
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
||||
|
||||
@@ -36,7 +39,6 @@ export interface PaymentMethodSelectionProps {
|
||||
selectedPaymentMethod?: string
|
||||
onSelectPaymentMethod: (id: string) => void
|
||||
layout?: 'vertical' | 'horizontal'
|
||||
createPaymentMethodInline: boolean
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
@@ -45,27 +47,48 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
selectedPaymentMethod,
|
||||
onSelectPaymentMethod,
|
||||
layout = 'vertical',
|
||||
createPaymentMethodInline = false,
|
||||
readOnly,
|
||||
}: PaymentMethodSelectionProps,
|
||||
ref
|
||||
) {
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const slug = selectedOrganization?.slug
|
||||
const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false)
|
||||
const captchaLoaded = useIsHCaptchaLoaded()
|
||||
const { slug } = useParams()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
|
||||
const [setupIntent, setSetupIntent] = useState<SetupIntentResponse | undefined>(undefined)
|
||||
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const paymentRef = useRef<{ createPaymentMethod: () => Promise<PaymentMethod | undefined> }>(null)
|
||||
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
|
||||
const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState<boolean | null>(null)
|
||||
const { data: customerProfile, isLoading: isCustomerProfileLoading } =
|
||||
useOrganizationCustomerProfileQuery({
|
||||
slug,
|
||||
})
|
||||
const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ slug })
|
||||
|
||||
const {
|
||||
data: paymentMethods,
|
||||
isLoading,
|
||||
refetch: refetchPaymentMethods,
|
||||
} = useOrganizationPaymentMethodsQuery({ slug })
|
||||
const hidePaymentMethodsWithoutAddress = useFlag('hidePaymentMethodsWithoutAddress')
|
||||
|
||||
const { data: allPaymentMethods, isLoading } = useOrganizationPaymentMethodsQuery({ slug })
|
||||
|
||||
const paymentMethods = useMemo(() => {
|
||||
if (!allPaymentMethods)
|
||||
return {
|
||||
data: [],
|
||||
defaultPaymentMethodId: null,
|
||||
}
|
||||
|
||||
const filtered = allPaymentMethods.data.filter(
|
||||
(pm) => !hidePaymentMethodsWithoutAddress || pm.has_address
|
||||
)
|
||||
return {
|
||||
data: filtered,
|
||||
defaultPaymentMethodId: allPaymentMethods.data.some(
|
||||
(pm) => pm.id === allPaymentMethods.defaultPaymentMethodId
|
||||
)
|
||||
? allPaymentMethods.defaultPaymentMethodId
|
||||
: null,
|
||||
}
|
||||
}, [allPaymentMethods])
|
||||
|
||||
const captchaRefCallback = useCallback((node: any) => {
|
||||
setCaptchaRef(node)
|
||||
@@ -98,7 +121,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
}
|
||||
|
||||
const loadPaymentForm = async () => {
|
||||
if (setupNewPaymentMethod && createPaymentMethodInline && captchaRef && captchaLoaded) {
|
||||
if (setupNewPaymentMethod && captchaRef) {
|
||||
let token = captchaToken
|
||||
|
||||
try {
|
||||
@@ -116,18 +139,13 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
}
|
||||
|
||||
loadPaymentForm()
|
||||
}, [createPaymentMethodInline, captchaRef, captchaLoaded, setupNewPaymentMethod])
|
||||
}, [captchaRef, setupNewPaymentMethod])
|
||||
|
||||
const resetCaptcha = () => {
|
||||
setCaptchaToken(null)
|
||||
captchaRef?.resetCaptcha()
|
||||
}
|
||||
|
||||
const canUpdatePaymentMethods = useCheckPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.payment_methods'
|
||||
)
|
||||
|
||||
const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
|
||||
() =>
|
||||
({
|
||||
@@ -156,11 +174,27 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
}, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod])
|
||||
|
||||
// If createPaymentMethod already exists, use it. Otherwise, define it here.
|
||||
const createPaymentMethod = async () => {
|
||||
const createPaymentMethod = async (): ReturnType<
|
||||
PaymentMethodElementRef['createPaymentMethod']
|
||||
> => {
|
||||
if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) {
|
||||
return paymentRef.current?.createPaymentMethod()
|
||||
const paymentResult = await paymentRef.current?.createPaymentMethod()
|
||||
|
||||
if (!paymentResult) return paymentResult
|
||||
|
||||
return {
|
||||
paymentMethod: paymentResult.paymentMethod,
|
||||
customerName: useAsDefaultBillingAddress ? paymentResult.customerName : null,
|
||||
address: useAsDefaultBillingAddress ? paymentResult.address : null,
|
||||
taxId: useAsDefaultBillingAddress ? paymentResult.taxId : null,
|
||||
}
|
||||
} else {
|
||||
return { id: selectedPaymentMethod }
|
||||
return {
|
||||
paymentMethod: { id: selectedPaymentMethod } as PaymentMethod,
|
||||
customerName: useAsDefaultBillingAddress ? customerProfile?.billing_name || '' : null,
|
||||
address: useAsDefaultBillingAddress ? customerProfile?.address ?? null : null,
|
||||
taxId: useAsDefaultBillingAddress ? taxId ?? null : null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,42 +231,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
<Loader className="animate-spin" size={14} />
|
||||
<p className="text-sm text-foreground-light">Retrieving payment methods</p>
|
||||
</div>
|
||||
) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? (
|
||||
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative">
|
||||
<div className="flex items-center space-x-4 text-foreground-light">
|
||||
<AlertCircle size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm">No payment methods</p>
|
||||
</div>
|
||||
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!canUpdatePaymentMethods}
|
||||
icon={<CreditCard />}
|
||||
onClick={() => {
|
||||
if (createPaymentMethodInline) {
|
||||
setSetupNewPaymentMethod(true)
|
||||
} else {
|
||||
setShowAddNewPaymentMethodModal(true)
|
||||
}
|
||||
}}
|
||||
htmlType="button"
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdatePaymentMethods ? (
|
||||
<div className="w-48 text-center">
|
||||
<span>
|
||||
You need additional permissions to add new payment methods to this
|
||||
organization
|
||||
</span>
|
||||
</div>
|
||||
) : undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add new
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? (
|
||||
<Listbox
|
||||
layout={layout}
|
||||
@@ -267,11 +265,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
<div
|
||||
className="flex items-center px-3 py-2 space-x-2 transition cursor-pointer group hover:bg-surface-300"
|
||||
onClick={() => {
|
||||
if (createPaymentMethodInline) {
|
||||
setSetupNewPaymentMethod(true)
|
||||
} else {
|
||||
setShowAddNewPaymentMethodModal(true)
|
||||
}
|
||||
setSetupNewPaymentMethod(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
@@ -282,17 +276,39 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
</Listbox>
|
||||
) : null}
|
||||
|
||||
{stripePromise && setupIntent && (
|
||||
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
|
||||
<NewPaymentMethodElement
|
||||
ref={paymentRef}
|
||||
email={selectedOrganization?.billing_email ?? undefined}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</Elements>
|
||||
{stripePromise && setupIntent && customerProfile && (
|
||||
<>
|
||||
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
|
||||
<NewPaymentMethodElement
|
||||
ref={paymentRef}
|
||||
email={selectedOrganization?.billing_email ?? undefined}
|
||||
readOnly={readOnly}
|
||||
customerName={customerProfile?.billing_name}
|
||||
currentAddress={customerProfile?.address}
|
||||
currentTaxId={taxId}
|
||||
/>
|
||||
</Elements>
|
||||
|
||||
{/* If the customer already has a billing address, optionally allow overwriting it - if they have no address, we use that as a default */}
|
||||
{customerProfile?.address != null && (
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Checkbox_Shadcn_
|
||||
id="defaultBillingAddress"
|
||||
checked={useAsDefaultBillingAddress}
|
||||
onCheckedChange={() => setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="defaultBillingAddress"
|
||||
className="text-sm leading-none text-foreground-light"
|
||||
>
|
||||
Use address as my org's billing address
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{setupIntentLoading && (
|
||||
{(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader className="h-10" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -303,26 +319,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddNewPaymentMethodModal
|
||||
visible={showAddNewPaymentMethodModal}
|
||||
returnUrl={`${getURL()}/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan&source=paymentMethod`}
|
||||
onCancel={() => setShowAddNewPaymentMethodModal(false)}
|
||||
autoMarkAsDefaultPaymentMethod={true}
|
||||
onConfirm={async () => {
|
||||
setShowAddNewPaymentMethodModal(false)
|
||||
toast.success('Successfully added new payment method')
|
||||
const { data: refetchedPaymentMethods } = await refetchPaymentMethods()
|
||||
if (refetchedPaymentMethods?.data?.length) {
|
||||
// Preselect the card that was just added
|
||||
const mostRecentPaymentMethod = refetchedPaymentMethods?.data.reduce(
|
||||
(prev, current) => (prev.created > current.created ? prev : current),
|
||||
refetchedPaymentMethods.data[0]
|
||||
)
|
||||
onSelectPaymentMethod(mostRecentPaymentMethod.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
+7
-3
@@ -18,7 +18,10 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
|
||||
import type { OrgPlan } from 'data/subscriptions/types'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
useSelectedOrganization,
|
||||
useSelectedOrganizationQuery,
|
||||
} from 'hooks/misc/useSelectedOrganization'
|
||||
import { formatCurrency } from 'lib/helpers'
|
||||
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
|
||||
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
|
||||
@@ -26,14 +29,15 @@ import { Button, SidePanel, cn } from 'ui'
|
||||
import DowngradeModal from './DowngradeModal'
|
||||
import { EnterpriseCard } from './EnterpriseCard'
|
||||
import { ExitSurveyModal } from './ExitSurveyModal'
|
||||
import { useParams } from 'common'
|
||||
import MembersExceedLimitModal from './MembersExceedLimitModal'
|
||||
import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog'
|
||||
import UpgradeSurveyModal from './UpgradeModal'
|
||||
|
||||
const PlanUpdateSidePanel = () => {
|
||||
const router = useRouter()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const slug = selectedOrganization?.slug
|
||||
const { slug } = useParams()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const originalPlanRef = useRef<string>()
|
||||
|
||||
+92
-106
@@ -13,22 +13,23 @@ import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizatio
|
||||
import { ProjectInfo } from 'data/projects/projects-query'
|
||||
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
|
||||
import { SubscriptionTier } from 'data/subscriptions/types'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS, STRIPE_PUBLIC_KEY } from 'lib/constants'
|
||||
import { formatCurrency } from 'lib/helpers'
|
||||
import { Badge, Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
|
||||
import { Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { BillingCustomerDataExistingOrgDialog } from '../BillingCustomerData/BillingCustomerDataExistingOrgDialog'
|
||||
import PaymentMethodSelection from './PaymentMethodSelection'
|
||||
import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change'
|
||||
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
|
||||
import { Elements } from '@stripe/react-stripe-js'
|
||||
import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
|
||||
import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { PaymentIntentResult } from '@stripe/stripe-js'
|
||||
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
|
||||
import { plans as subscriptionsPlans } from 'shared-data/plans'
|
||||
import type { PaymentMethodElementRef } from '../PaymentMethods/NewPaymentMethodElement'
|
||||
import { useParams } from 'common'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
||||
|
||||
@@ -76,12 +77,12 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
projects,
|
||||
}: Props) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
|
||||
const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null)
|
||||
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
|
||||
const paymentMethodSelection = useRef<{
|
||||
createPaymentMethod: () => Promise<PaymentMethod | undefined>
|
||||
const paymentMethodSelectionRef = useRef<{
|
||||
createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
|
||||
}>(null)
|
||||
|
||||
const billingViaPartner = subscription?.billing_via_partner === true
|
||||
@@ -161,18 +162,14 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
|
||||
setPaymentConfirmationLoading(true)
|
||||
|
||||
const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod()
|
||||
if (paymentMethod) {
|
||||
setSelectedPaymentMethod(paymentMethod.id)
|
||||
const result = await paymentMethodSelectionRef.current?.createPaymentMethod()
|
||||
if (result) {
|
||||
setSelectedPaymentMethod(result.paymentMethod.id)
|
||||
} else {
|
||||
setPaymentConfirmationLoading(false)
|
||||
}
|
||||
|
||||
if (
|
||||
!paymentMethod &&
|
||||
subscription?.payment_method_type !== 'invoice' &&
|
||||
changeType === 'upgrade'
|
||||
) {
|
||||
if (!result && subscription?.payment_method_type !== 'invoice' && changeType === 'upgrade') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,7 +182,10 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
updateOrgSubscription({
|
||||
slug: selectedOrganization?.slug,
|
||||
tier,
|
||||
paymentMethod: paymentMethod?.id,
|
||||
paymentMethod: result?.paymentMethod?.id,
|
||||
address: result?.address,
|
||||
tax_id: result?.taxId ?? undefined,
|
||||
billing_name: result?.customerName ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,13 +246,40 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
{/* Left Column */}
|
||||
<div className="p-8 pb-8 flex flex-col xl:col-span-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base mb-4">
|
||||
{changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
|
||||
<span className="font-bold">{selectedOrganization?.name}</span> to{' '}
|
||||
{changeType === 'downgrade'
|
||||
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
|
||||
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
|
||||
</h3>
|
||||
<div>
|
||||
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
|
||||
<div className="space-y-2 mb-4">
|
||||
<PaymentMethodSelection
|
||||
ref={paymentMethodSelectionRef}
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
|
||||
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{billingViaPartner && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm">
|
||||
This organization is billed through our partner{' '}
|
||||
{billingPartnerLabel(billingPartner)}.{' '}
|
||||
{billingPartner === 'aws' ? (
|
||||
<>The organization's credit balance will be decreased accordingly.</>
|
||||
) : (
|
||||
<>You will be charged by them directly.</>
|
||||
)}
|
||||
</p>
|
||||
{billingViaPartner &&
|
||||
billingPartner === 'fly' &&
|
||||
subscriptionPreview?.plan_change_type === 'downgrade' && (
|
||||
<p className="text-sm">
|
||||
Your organization will be downgraded at the end of your current billing
|
||||
cycle.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2 mb-4 mt-2">
|
||||
@@ -264,48 +291,6 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
{subscriptionPreviewInitialized && (
|
||||
<>
|
||||
<div className="mt-2 mb-4 text-foreground-light text-sm">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-muted">
|
||||
<div className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>{subscriptionPlanMeta?.name} Plan</span>
|
||||
<Badge variant={'brand'} size={'small'} className="ml-1">
|
||||
New
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="py-2 pr-0 text-right" translate="no">
|
||||
{formatCurrency(newPlanCost)}
|
||||
</div>
|
||||
</div>
|
||||
{subscription?.plan?.id !== 'free' && (
|
||||
<div className="flex items-center justify-between gap-2 border-b border-muted">
|
||||
<div className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Unused Time on {subscription?.plan?.name} Plan</span>
|
||||
<InfoTooltip className="max-w-sm">
|
||||
Your previous plan was charged upfront, so a plan change will prorate
|
||||
any unused time in credits. If the prorated credits exceed the new plan
|
||||
charge, the excessive credits are added to your organization for future
|
||||
use.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div className="py-2 pr-0 text-right" translate="no">
|
||||
-{formatCurrency(proratedCredit)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ignore rare case with negative balance (debt) */}
|
||||
{customerBalance > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 border-b border-muted">
|
||||
<div className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Credits</span>
|
||||
<InfoTooltip>
|
||||
Credits will be used first before charging your card.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div className="py-2 pr-0 text-right" translate="no">
|
||||
{formatCurrency(customerBalance)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-muted text-foreground">
|
||||
<div className="py-2 pl-0">Charge today</div>
|
||||
<div className="py-2 pr-0 text-right" translate="no">
|
||||
@@ -324,6 +309,39 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subscription?.plan?.id !== 'free' && (
|
||||
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
|
||||
<div className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Unused Time on {subscription?.plan?.name} Plan</span>
|
||||
<InfoTooltip className="max-w-sm">
|
||||
Your previous plan was charged upfront, so a plan change will prorate
|
||||
any unused time in credits. If the prorated credits exceed the new plan
|
||||
charge, the excessive credits are added to your organization for future
|
||||
use.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div className="py-2 pr-0 text-right" translate="no">
|
||||
-{formatCurrency(proratedCredit)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ignore rare case with negative balance (debt) */}
|
||||
{customerBalance > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
|
||||
<div className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Credits</span>
|
||||
<InfoTooltip>
|
||||
Credits will be used first before charging your card.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div className="py-2 pr-0 text-right" translate="no">
|
||||
{formatCurrency(customerBalance)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 text-foreground-lighter text-xs">
|
||||
<div className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Monthly invoice estimate</span>
|
||||
@@ -542,47 +560,11 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
|
||||
<div className="space-y-2 mb-4">
|
||||
<BillingCustomerDataExistingOrgDialog />
|
||||
|
||||
<PaymentMethodSelection
|
||||
ref={paymentMethodSelection}
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
|
||||
createPaymentMethodInline={true}
|
||||
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{billingViaPartner && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm">
|
||||
This organization is billed through our partner{' '}
|
||||
{billingPartnerLabel(billingPartner)}.{' '}
|
||||
{billingPartner === 'aws' ? (
|
||||
<>The organization's credit balance will be decreased accordingly.</>
|
||||
) : (
|
||||
<>You will be charged by them directly.</>
|
||||
)}
|
||||
</p>
|
||||
{billingViaPartner &&
|
||||
billingPartner === 'fly' &&
|
||||
subscriptionPreview?.plan_change_type === 'downgrade' && (
|
||||
<p className="text-sm">
|
||||
Your organization will be downgraded at the end of your current billing
|
||||
cycle.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.filter(
|
||||
(it) =>
|
||||
it.status === PROJECT_STATUS.ACTIVE_HEALTHY ||
|
||||
it.status === PROJECT_STATUS.COMING_UP
|
||||
).length === 0 &&
|
||||
).length === 5 &&
|
||||
subscriptionPreview?.plan_change_type !== 'downgrade' && (
|
||||
<div className="pb-2">
|
||||
<Admonition title="Empty organization" type="warning">
|
||||
@@ -618,16 +600,13 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button type="default" size="medium" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isUpdating || paymentConfirmationLoading || isConfirming}
|
||||
disabled={subscriptionPreviewIsLoading}
|
||||
type="primary"
|
||||
onClick={onUpdateSubscription}
|
||||
className="flex-1"
|
||||
size="medium"
|
||||
size="small"
|
||||
>
|
||||
Confirm {changeType === 'downgrade' ? 'downgrade' : 'upgrade'}
|
||||
</Button>
|
||||
@@ -637,6 +616,13 @@ export const SubscriptionPlanUpdateDialog = ({
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="bg-surface-100 p-8 flex flex-col border-l xl:col-span-2">
|
||||
<h3 className="text-base mb-8">
|
||||
{changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
|
||||
<span className="font-bold">{selectedOrganization?.name}</span> to{' '}
|
||||
{changeType === 'downgrade'
|
||||
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
|
||||
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
|
||||
</h3>
|
||||
{changeType === 'downgrade'
|
||||
? featuresToLose.length > 0 && (
|
||||
<div className="mb-4">
|
||||
|
||||
+4
-4
@@ -11,20 +11,20 @@ import PartnerManagedResource from 'components/ui/PartnerManagedResource'
|
||||
import { getInvoice } from 'data/invoices/invoice-query'
|
||||
import { useInvoicesCountQuery } from 'data/invoices/invoices-count-query'
|
||||
import { useInvoicesQuery } from 'data/invoices/invoices-query'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { formatCurrency } from 'lib/helpers'
|
||||
import { ChevronLeft, ChevronRight, Download, FileText } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import InvoicePayButton from './InvoicePayButton'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
const PAGE_LIMIT = 5
|
||||
|
||||
const InvoicesSettings = () => {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const { slug } = selectedOrganization ?? {}
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const slug = selectedOrganization?.slug
|
||||
const offset = (page - 1) * PAGE_LIMIT
|
||||
|
||||
const { data: count, isError: isErrorCount } = useInvoicesCountQuery(
|
||||
@@ -33,7 +33,7 @@ const InvoicesSettings = () => {
|
||||
},
|
||||
{ enabled: selectedOrganization?.managed_by === 'supabase' }
|
||||
)
|
||||
const { data, error, isLoading, isError, isSuccess } = useInvoicesQuery(
|
||||
const { data, error, isLoading, isError } = useInvoicesQuery(
|
||||
{
|
||||
slug,
|
||||
offset,
|
||||
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { BillingCustomerDataNewOrgDialog } from '../BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog'
|
||||
import { FormCustomerData } from '../BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
|
||||
import { useConfirmPendingSubscriptionCreateMutation } from 'data/subscriptions/org-subscription-confirm-pending-create'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import { useTheme } from 'next-themes'
|
||||
@@ -41,7 +39,12 @@ import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
|
||||
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
|
||||
import { NewPaymentMethodElement } from '../BillingSettings/PaymentMethods/NewPaymentMethodElement'
|
||||
import {
|
||||
NewPaymentMethodElement,
|
||||
type PaymentMethodElementRef,
|
||||
} from '../BillingSettings/PaymentMethods/NewPaymentMethodElement'
|
||||
import { components } from 'api-types'
|
||||
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
|
||||
|
||||
const ORG_KIND_TYPES = {
|
||||
PERSONAL: 'Personal',
|
||||
@@ -90,6 +93,8 @@ type FormState = z.infer<typeof formSchema>
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
||||
|
||||
const newMandatoryAddressInput = true
|
||||
|
||||
/**
|
||||
* No org selected yet, create a new one
|
||||
* [Joshen] Need to refactor to use Form_Shadcn here
|
||||
@@ -115,8 +120,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
|
||||
const [isOrgCreationConfirmationModalVisible, setIsOrgCreationConfirmationModalVisible] =
|
||||
useState(false)
|
||||
|
||||
const [customerData, setCustomerData] = useState<FormCustomerData | null>(null)
|
||||
|
||||
const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
|
||||
() =>
|
||||
({
|
||||
@@ -244,7 +247,14 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
|
||||
} as StripeElementsOptions
|
||||
}, [paymentIntentSecret, resolvedTheme])
|
||||
|
||||
async function createOrg(paymentMethodId?: string) {
|
||||
async function createOrg(
|
||||
paymentMethodId?: string,
|
||||
customerData?: {
|
||||
address: CustomerAddress | null
|
||||
billing_name: string | null
|
||||
tax_id: CustomerTaxId | null
|
||||
}
|
||||
) {
|
||||
const dbTier = formState.plan === 'PRO' && !formState.spend_cap ? 'PAYG' : formState.plan
|
||||
|
||||
createOrganization({
|
||||
@@ -258,12 +268,12 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
|
||||
...(formState.kind == 'COMPANY' ? { size: formState.size } : {}),
|
||||
payment_method: paymentMethodId,
|
||||
billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name,
|
||||
address: dbTier === 'FREE' ? undefined : customerData?.address,
|
||||
address: dbTier === 'FREE' ? null : customerData?.address,
|
||||
tax_id: dbTier === 'FREE' ? undefined : customerData?.tax_id ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const paymentRef = useRef<{ createPaymentMethod: () => Promise<any> }>(null)
|
||||
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setNewOrgLoading(true)
|
||||
@@ -271,10 +281,16 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
|
||||
if (formState.plan === 'FREE') {
|
||||
await createOrg()
|
||||
} else if (!paymentMethod) {
|
||||
const paymentMethod = await paymentRef.current?.createPaymentMethod()
|
||||
if (paymentMethod) {
|
||||
setPaymentMethod(paymentMethod)
|
||||
createOrg(paymentMethod.id)
|
||||
const result = await paymentRef.current?.createPaymentMethod()
|
||||
if (result) {
|
||||
setPaymentMethod(result.paymentMethod)
|
||||
const customerData = {
|
||||
address: result.address,
|
||||
billing_name: result.customerName,
|
||||
tax_id: result.taxId,
|
||||
}
|
||||
|
||||
createOrg(result.paymentMethod.id, customerData)
|
||||
} else {
|
||||
setNewOrgLoading(false)
|
||||
}
|
||||
@@ -340,6 +356,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<Panel.Content>
|
||||
<p className="text-sm">This is your organization within Supabase.</p>
|
||||
@@ -532,21 +549,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
|
||||
</>
|
||||
)}
|
||||
|
||||
{formState.plan !== 'FREE' && (
|
||||
<Panel.Content className="border-b border-panel-border-interior-light dark:border-panel-border-interior-dark">
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="col-span-1 flex space-x-2 text-sm items-center">
|
||||
<Label_Shadcn_ htmlFor="spend-cap" className=" leading-normal">
|
||||
Billing Address
|
||||
</Label_Shadcn_>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<BillingCustomerDataNewOrgDialog onCustomerDataChange={setCustomerData} />
|
||||
</div>
|
||||
</div>
|
||||
</Panel.Content>
|
||||
)}
|
||||
|
||||
{setupIntent && formState.plan !== 'FREE' && (
|
||||
<Panel.Content>
|
||||
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
|
||||
|
||||
@@ -15,17 +15,19 @@ export const OrgNotFound = ({ slug }: { slug?: string }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Admonition type="danger">
|
||||
The selected organization does not exist or you don't have permission to access it.{' '}
|
||||
{slug ? (
|
||||
<>
|
||||
Contact the owner or administrator to create a new project in the <code>{slug}</code>{' '}
|
||||
organization.
|
||||
</>
|
||||
) : (
|
||||
<>Contact the owner or administrator to create a new project.</>
|
||||
)}
|
||||
</Admonition>
|
||||
{slug !== '_' && (
|
||||
<Admonition type="danger">
|
||||
The selected organization does not exist or you don't have permission to access it.{' '}
|
||||
{slug ? (
|
||||
<>
|
||||
Contact the owner or administrator to create a new project in the <code>{slug}</code>{' '}
|
||||
organization.
|
||||
</>
|
||||
) : (
|
||||
<>Contact the owner or administrator to create a new project.</>
|
||||
)}
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<h3 className="text-sm">Select an organization to create your new project from</h3>
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
/>
|
||||
<div className="flex item-center gap-x-2">
|
||||
<p className="text-foreground-light truncate">{member.primary_email}</p>
|
||||
{member.primary_email === profile?.primary_email && <Badge color="scale">You</Badge>}
|
||||
{member.gotrue_id === profile?.gotrue_id && <Badge color="scale">You</Badge>}
|
||||
</div>
|
||||
|
||||
{(member.metadata as any)?.origin && (
|
||||
|
||||
@@ -54,13 +54,13 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
|
||||
const [[user], otherMembers] = partition(
|
||||
filteredMembers,
|
||||
(m) => m.primary_email === profile?.primary_email
|
||||
(m) => m.gotrue_id === profile?.gotrue_id
|
||||
)
|
||||
const sortedMembers = otherMembers.sort((a, b) =>
|
||||
(a.primary_email ?? '').localeCompare(b.primary_email ?? '')
|
||||
)
|
||||
|
||||
const userMember = members.find((m) => m.primary_email === profile?.primary_email)
|
||||
const userMember = members.find((m) => m.gotrue_id === profile?.gotrue_id)
|
||||
const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
|
||||
const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldTitle,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { InviteMemberButton } from './InviteMemberButton'
|
||||
import MembersView from './MembersView'
|
||||
@@ -32,6 +33,7 @@ export const TeamSettings = () => {
|
||||
placeholder="Filter members"
|
||||
/>
|
||||
<ScaffoldActionsGroup className="w-full md:w-auto">
|
||||
<DocsButton href="https://supabase.com/docs/guides/platform/access-control" />
|
||||
<InviteMemberButton />
|
||||
</ScaffoldActionsGroup>
|
||||
</ScaffoldActionsContainer>
|
||||
|
||||
+6
-3
@@ -90,10 +90,11 @@ export const UpdateRolesConfirmationModal = ({
|
||||
})
|
||||
toast.success(`Successfully updated role for ${member.username}`)
|
||||
onClose(true)
|
||||
return
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to update role: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
return toast.error(`Failed to update role: ${error.message}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +131,10 @@ export const UpdateRolesConfirmationModal = ({
|
||||
toast.success(`Successfully updated role for ${member.username}`)
|
||||
onClose(true)
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to update role: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
return toast.error(`Failed to update role: ${error.message}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,8 +195,8 @@ const Usage = () => {
|
||||
title="Usage filtered by project"
|
||||
description={
|
||||
<div>
|
||||
You are currently viewing usage for the "
|
||||
{selectedProject?.name || selectedProjectRef}" project. Supabase uses{' '}
|
||||
You are currently viewing usage for the
|
||||
{selectedProject?.name || selectedProjectRef} project. Supabase uses{' '}
|
||||
<Link
|
||||
href="/docs/guides/platform/billing-on-supabase#organization-based-billing"
|
||||
target="_blank"
|
||||
|
||||
+1
-1
@@ -224,7 +224,7 @@ const AttributeUsage = ({
|
||||
</div>
|
||||
) : chartData.length > 0 && notAllValuesZero ? (
|
||||
<UsageBarChart
|
||||
name={`${attribute.chartPrefix || ''}${attribute.name}`}
|
||||
name={`${attribute.chartPrefix || ''} ${attribute.name}`}
|
||||
unit={attribute.unit}
|
||||
attributes={attribute.attributes}
|
||||
data={chartData}
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
* This component acts as a bridge between the data-fetching logic and the
|
||||
* presentational chart component.
|
||||
*/
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
import LogChartHandler from 'components/ui/Charts/LogChartHandler'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useChartData } from 'hooks/useChartData'
|
||||
import type { UpdateDateRange } from 'pages/project/[ref]/reports/database'
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import Link from 'next/link'
|
||||
import { Button, cn } from 'ui'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
const ReportChart = ({
|
||||
chart,
|
||||
@@ -25,9 +28,7 @@ const ReportChart = ({
|
||||
interval,
|
||||
updateDateRange,
|
||||
functionIds,
|
||||
orgPlanId,
|
||||
isLoading,
|
||||
availableIn,
|
||||
className,
|
||||
}: {
|
||||
chart: any
|
||||
@@ -36,15 +37,17 @@ const ReportChart = ({
|
||||
interval: string
|
||||
updateDateRange: UpdateDateRange
|
||||
functionIds?: string[]
|
||||
orgPlanId?: string
|
||||
isLoading?: boolean
|
||||
availableIn?: string[]
|
||||
className?: string
|
||||
}) => {
|
||||
const org = useSelectedOrganization()
|
||||
const { plan: orgPlan } = useCurrentOrgPlan()
|
||||
const orgPlanId = orgPlan?.id
|
||||
|
||||
const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false)
|
||||
const isAvailable =
|
||||
chart.availableIn === undefined || (orgPlanId && chart.availableIn.includes(orgPlanId))
|
||||
|
||||
const canFetch = orgPlanId !== undefined
|
||||
const {
|
||||
data,
|
||||
@@ -114,7 +117,9 @@ const ReportChart = ({
|
||||
<h2 className="">{chart.label}</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
This chart is available from{' '}
|
||||
<span className="capitalize">{!!availableIn?.length ? availableIn[0] : 'Pro'}</span>{' '}
|
||||
<span className="capitalize">
|
||||
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
|
||||
</span>{' '}
|
||||
plan and above
|
||||
</p>
|
||||
<Button
|
||||
@@ -125,7 +130,9 @@ const ReportChart = ({
|
||||
>
|
||||
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=reports`}>
|
||||
Upgrade to{' '}
|
||||
<span className="capitalize">{!!availableIn?.length ? availableIn[0] : 'Pro'}</span>
|
||||
<span className="capitalize">
|
||||
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -416,7 +416,7 @@ const InfrastructureActivity = () => {
|
||||
</div>
|
||||
) : chartData.length ? (
|
||||
<UsageBarChart
|
||||
name={`${attribute.chartPrefix || ''}${attribute.name}`}
|
||||
name={`${attribute.chartPrefix || ''} ${attribute.name}`}
|
||||
unit={attribute.unit}
|
||||
attributes={attribute.attributes}
|
||||
data={chartData}
|
||||
|
||||
@@ -65,8 +65,9 @@ const InfrastructureInfo = () => {
|
||||
const currentPgVersion = (current_app_version ?? '')
|
||||
.split('supabase-postgres-')[1]
|
||||
?.replace('-orioledb', '')
|
||||
const isOnNonGenerallyAvailableReleaseChannel =
|
||||
current_app_version_release_channel && current_app_version_release_channel !== 'ga'
|
||||
const isVisibleReleaseChannel =
|
||||
current_app_version_release_channel &&
|
||||
!['ga', 'withdrawn'].includes(current_app_version_release_channel)
|
||||
? current_app_version_release_channel
|
||||
: undefined
|
||||
const isOrioleDb = useIsOrioleDb()
|
||||
@@ -144,16 +145,16 @@ const InfrastructureInfo = () => {
|
||||
value={currentPgVersion || serviceVersions?.['supabase-postgres'] || ''}
|
||||
label="Postgres version"
|
||||
actions={[
|
||||
isOnNonGenerallyAvailableReleaseChannel && (
|
||||
isVisibleReleaseChannel && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge variant="warning" className="mr-1 capitalize">
|
||||
{isOnNonGenerallyAvailableReleaseChannel}
|
||||
{isVisibleReleaseChannel}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="w-44 text-center">
|
||||
This project uses a {isOnNonGenerallyAvailableReleaseChannel}{' '}
|
||||
database version release
|
||||
This project uses a {isVisibleReleaseChannel} database version
|
||||
release
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface LogSelectionProps {
|
||||
onClose: () => void
|
||||
queryType?: QueryType
|
||||
projectRef: string
|
||||
collectionName?: string
|
||||
isLoading: boolean
|
||||
error?: string | object
|
||||
}
|
||||
@@ -41,8 +40,6 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio
|
||||
if (!log) return <LogDetailEmptyState />
|
||||
|
||||
switch (queryType) {
|
||||
// case 'warehouse':
|
||||
// return <WarehouseSelectionRenderer log={log} />
|
||||
case 'api':
|
||||
const status = log?.metadata?.[0]?.response?.[0]?.status_code
|
||||
const method = log?.metadata?.[0]?.request?.[0]?.method
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { cn } from 'ui'
|
||||
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
|
||||
import { jsonSyntaxHighlight, SelectionDetailedRow } from '../LogsFormatters'
|
||||
|
||||
const DetailedJsonRow = ({
|
||||
label,
|
||||
value,
|
||||
code,
|
||||
}: {
|
||||
label: string
|
||||
value: Object
|
||||
code?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid grid-cols-12">
|
||||
<span className="text-foreground-lighter text-sm col-span-3">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground text-sm col-span-9 overflow-x-auto',
|
||||
code && 'text-xs font-mono'
|
||||
)}
|
||||
>
|
||||
<pre
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: jsonSyntaxHighlight(value),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WarehouseSelectionRenderer = ({ log }: any) => {
|
||||
return (
|
||||
<div className="overflow-hidden overflow-x-auto space-y-6">
|
||||
{Object.entries(log).map(([key, value], index) => {
|
||||
return (
|
||||
<div
|
||||
key={`${key}-${index}`}
|
||||
className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}
|
||||
>
|
||||
{value && typeof value === 'object' ? (
|
||||
<DetailedJsonRow label={key} value={value} />
|
||||
) : (
|
||||
<SelectionDetailedRow label={key} value={value === null ? 'NULL ' : String(value)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -49,8 +49,6 @@ interface Props {
|
||||
onSave?: () => void
|
||||
hasEditorValue?: boolean
|
||||
className?: string
|
||||
collectionName?: string // Used for warehouse queries
|
||||
warehouseError?: string
|
||||
EmptyState?: ReactNode
|
||||
showHeader?: boolean
|
||||
showHistogramToggle?: boolean
|
||||
@@ -79,7 +77,6 @@ const LogTable = ({
|
||||
onSave,
|
||||
hasEditorValue,
|
||||
className,
|
||||
collectionName,
|
||||
EmptyState,
|
||||
showHeader = true,
|
||||
showHistogramToggle = true,
|
||||
@@ -154,9 +151,6 @@ const LogTable = ({
|
||||
columns
|
||||
} else {
|
||||
switch (queryType) {
|
||||
case 'warehouse':
|
||||
columns = DEFAULT_COLUMNS
|
||||
break
|
||||
case 'api':
|
||||
columns = DatabaseApiColumnRender
|
||||
break
|
||||
@@ -460,7 +454,6 @@ const LogTable = ({
|
||||
log={selectedLog}
|
||||
error={selectedLogError}
|
||||
queryType={queryType}
|
||||
collectionName={collectionName}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
)}
|
||||
|
||||
@@ -369,7 +369,6 @@ export enum LogsTableName {
|
||||
POSTGREST = 'postgrest_logs',
|
||||
SUPAVISOR = 'supavisor_logs',
|
||||
PGBOUNCER = 'pgbouncer_logs',
|
||||
WAREHOUSE = 'warehouse_logs',
|
||||
PG_UPGRADE = 'pg_upgrade_logs',
|
||||
PG_CRON = 'pg_cron_logs',
|
||||
}
|
||||
@@ -384,7 +383,6 @@ export const LOGS_TABLES = {
|
||||
storage: LogsTableName.STORAGE,
|
||||
postgrest: LogsTableName.POSTGREST,
|
||||
supavisor: LogsTableName.SUPAVISOR,
|
||||
warehouse: LogsTableName.WAREHOUSE,
|
||||
pg_upgrade: LogsTableName.PG_UPGRADE,
|
||||
pg_cron: LogsTableName.POSTGRES,
|
||||
pgbouncer: LogsTableName.PGBOUNCER,
|
||||
@@ -401,7 +399,6 @@ export const LOGS_SOURCE_DESCRIPTION = {
|
||||
[LogsTableName.POSTGREST]: 'RESTful API web server logs',
|
||||
[LogsTableName.SUPAVISOR]: 'Shared connection pooler logs for PostgreSQL',
|
||||
[LogsTableName.PGBOUNCER]: 'Dedicated connection pooler for PostgreSQL',
|
||||
[LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection',
|
||||
[LogsTableName.PG_UPGRADE]: 'Logs generated by the Postgres version upgrade process',
|
||||
[LogsTableName.PG_CRON]: 'Postgres logs from pg_cron cron jobs',
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ export type QueryType =
|
||||
| 'storage'
|
||||
| 'supavisor'
|
||||
| 'postgrest'
|
||||
| 'warehouse'
|
||||
| 'pg_upgrade'
|
||||
| 'pg_cron'
|
||||
| 'pgbouncer'
|
||||
@@ -145,9 +144,3 @@ export interface DatetimeHelper {
|
||||
disabled?: boolean
|
||||
availableIn?: PlanId[]
|
||||
}
|
||||
|
||||
export interface WarehouseCollection {
|
||||
name: string
|
||||
id: number
|
||||
token: string
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { ReactNode, useState } from 'react'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { logConstants } from 'shared-data'
|
||||
import {
|
||||
Badge,
|
||||
@@ -28,23 +27,16 @@ import {
|
||||
LogsTableName,
|
||||
} from './Logs.constants'
|
||||
import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers'
|
||||
import { LogsWarning, LogTemplate, WarehouseCollection } from './Logs.types'
|
||||
import { WarehouseQueryTemplate } from './Warehouse.utils'
|
||||
import { LogsWarning, LogTemplate } from './Logs.types'
|
||||
|
||||
export type SourceType = 'logs' | 'warehouse'
|
||||
export interface LogsQueryPanelProps {
|
||||
templates?: LogTemplate[]
|
||||
warehouseTemplates?: WarehouseQueryTemplate[]
|
||||
defaultFrom: string
|
||||
defaultTo: string
|
||||
warnings: LogsWarning[]
|
||||
warehouseCollections: WarehouseCollection[]
|
||||
dataSource: SourceType
|
||||
onSelectTemplate: (template: LogTemplate) => void
|
||||
onSelectWarehouseTemplate: (template: WarehouseQueryTemplate) => void
|
||||
onSelectSource: (source: string) => void
|
||||
onDateChange: (value: DatePickerValue) => void
|
||||
onDataSourceChange: (sourceType: SourceType) => void
|
||||
}
|
||||
|
||||
function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: string }) {
|
||||
@@ -58,17 +50,12 @@ function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: strin
|
||||
|
||||
const LogsQueryPanel = ({
|
||||
templates = [],
|
||||
warehouseTemplates = [],
|
||||
defaultFrom,
|
||||
defaultTo,
|
||||
warnings,
|
||||
warehouseCollections,
|
||||
dataSource,
|
||||
onSelectTemplate,
|
||||
onSelectWarehouseTemplate,
|
||||
onSelectSource,
|
||||
onDateChange,
|
||||
onDataSourceChange,
|
||||
}: LogsQueryPanelProps) => {
|
||||
const [showReference, setShowReference] = useState(false)
|
||||
|
||||
@@ -78,15 +65,12 @@ const LogsQueryPanel = ({
|
||||
projectEdgeFunctionAll: edgeFunctionsEnabled,
|
||||
} = useIsFeatureEnabled(['project_auth:all', 'project_storage:all', 'project_edge_function:all'])
|
||||
|
||||
const warehouseEnabled = useFlag('warehouse')
|
||||
|
||||
const logsTableNames = Object.entries(LogsTableName)
|
||||
.filter(([key]) => {
|
||||
if (key === 'AUTH') return authEnabled
|
||||
if (key === 'STORAGE') return storageEnabled
|
||||
if (key === 'FN_EDGE') return edgeFunctionsEnabled
|
||||
if (key === 'PG_CRON') return false
|
||||
if (key === 'WAREHOUSE') return false
|
||||
return true
|
||||
})
|
||||
.map(([, value]) => value)
|
||||
@@ -117,90 +101,31 @@ const LogsQueryPanel = ({
|
||||
<div className="flex w-full items-center justify-between px-4 md:px-5 py-2 overflow-x-scroll no-scrollbar">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-x-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{warehouseEnabled && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
Data source{' '}
|
||||
<span className="ml-2 font-mono opacity-50">
|
||||
{dataSource === 'warehouse' ? 'collections' : 'logs'}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start">
|
||||
<DropdownMenuItem onClick={() => onDataSourceChange('logs')}>
|
||||
<DropdownMenuItemContent name="Logs" desc="Logs for all Supabase products" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onDataSourceChange('warehouse')}>
|
||||
<DropdownMenuItemContent
|
||||
name={
|
||||
<span>
|
||||
Collections <Badge variant="warning">NEW</Badge>
|
||||
</span>
|
||||
}
|
||||
desc="Query your collections"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{dataSource === 'warehouse' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
Templates
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-h-96 overflow-auto" side="bottom" align="start">
|
||||
{warehouseTemplates.map((template) => (
|
||||
<DropdownMenuItem
|
||||
key={template.name}
|
||||
onClick={() => onSelectWarehouseTemplate(template)}
|
||||
>
|
||||
<DropdownMenuItemContent name={template.name} desc={template.description} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{warehouseCollections.length === 0 && (
|
||||
<DropdownMenuItem className="hover:bg-transparent cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
Insert source
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-h-[70vh] overflow-auto"
|
||||
>
|
||||
{logsTableNames
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((source) => (
|
||||
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
|
||||
<DropdownMenuItemContent
|
||||
name="No collections found"
|
||||
desc="You can create collections in the left sidebar."
|
||||
name={source}
|
||||
desc={LOGS_SOURCE_DESCRIPTION[source]}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{dataSource === 'logs' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
Insert source
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-h-[70vh] overflow-auto"
|
||||
>
|
||||
{dataSource === 'logs' &&
|
||||
logsTableNames
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((source) => (
|
||||
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
|
||||
<DropdownMenuItemContent
|
||||
name={source}
|
||||
desc={LOGS_SOURCE_DESCRIPTION[source]}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{dataSource === 'logs' && IS_PLATFORM && (
|
||||
{IS_PLATFORM && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
@@ -222,16 +147,14 @@ const LogsQueryPanel = ({
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{dataSource === 'logs' && (
|
||||
<LogsDatePicker
|
||||
value={selectedDatePickerValue}
|
||||
onSubmit={(value) => {
|
||||
setSelectedDatePickerValue(value)
|
||||
onDateChange(value)
|
||||
}}
|
||||
helpers={EXPLORER_DATEPICKER_HELPERS}
|
||||
/>
|
||||
)}
|
||||
<LogsDatePicker
|
||||
value={selectedDatePickerValue}
|
||||
onSubmit={(value) => {
|
||||
setSelectedDatePickerValue(value)
|
||||
onDateChange(value)
|
||||
}}
|
||||
helpers={EXPLORER_DATEPICKER_HELPERS}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-testid="log-explorer-warnings"
|
||||
@@ -261,93 +184,92 @@ const LogsQueryPanel = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{dataSource === 'logs' && (
|
||||
<SidePanel
|
||||
size="large"
|
||||
header={
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<h3>Field Reference</h3>
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1"
|
||||
onClick={() => setShowReference(false)}
|
||||
icon={<X />}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
visible={showReference}
|
||||
cancelText="Close"
|
||||
onCancel={() => setShowReference(false)}
|
||||
hideFooter
|
||||
triggerElement={
|
||||
|
||||
<SidePanel
|
||||
size="large"
|
||||
header={
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<h3>Field Reference</h3>
|
||||
<Button
|
||||
asChild // ?: we don't want a button inside a button
|
||||
type="text"
|
||||
onClick={() => setShowReference(true)}
|
||||
icon={<BookOpen />}
|
||||
className="px-2"
|
||||
>
|
||||
<span>Field Reference</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="pt-4 pb-2 space-y-1">
|
||||
<p className="text-sm">
|
||||
The following table shows all the available paths that can be queried from each
|
||||
respective source. Do note that to access nested keys, you would need to perform
|
||||
the necessary{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/logs#unnesting-arrays"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand"
|
||||
>
|
||||
unnesting joins
|
||||
<ExternalLink
|
||||
size="14"
|
||||
className="ml-1 inline -translate-y-[2px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
<SidePanel.Separator />
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="edge_logs"
|
||||
listClassNames="px-2"
|
||||
className="px-1"
|
||||
onClick={() => setShowReference(false)}
|
||||
icon={<X />}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
visible={showReference}
|
||||
cancelText="Close"
|
||||
onCancel={() => setShowReference(false)}
|
||||
hideFooter
|
||||
triggerElement={
|
||||
<Button
|
||||
asChild // ?: we don't want a button inside a button
|
||||
type="text"
|
||||
onClick={() => setShowReference(true)}
|
||||
icon={<BookOpen />}
|
||||
className="px-2"
|
||||
>
|
||||
{logConstants.schemas.map((schema) => (
|
||||
<Tabs.Panel
|
||||
key={schema.reference}
|
||||
id={schema.reference}
|
||||
label={schema.name}
|
||||
className="px-4 pb-4"
|
||||
<span>Field Reference</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="pt-4 pb-2 space-y-1">
|
||||
<p className="text-sm">
|
||||
The following table shows all the available paths that can be queried from each
|
||||
respective source. Do note that to access nested keys, you would need to perform
|
||||
the necessary{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/logs#unnesting-arrays"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand"
|
||||
>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th className="text-xs !p-2" key="path">
|
||||
Path
|
||||
</Table.th>,
|
||||
<Table.th key="type" className="text-xs !p-2">
|
||||
Type
|
||||
</Table.th>,
|
||||
]}
|
||||
body={schema.fields
|
||||
.sort((a: any, b: any) => a.path - b.path)
|
||||
.map((field) => (
|
||||
<Field key={field.path} field={field} />
|
||||
))}
|
||||
unnesting joins
|
||||
<ExternalLink
|
||||
size="14"
|
||||
className="ml-1 inline -translate-y-[2px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</SidePanel>
|
||||
)}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
<SidePanel.Separator />
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="edge_logs"
|
||||
listClassNames="px-2"
|
||||
>
|
||||
{logConstants.schemas.map((schema) => (
|
||||
<Tabs.Panel
|
||||
key={schema.reference}
|
||||
id={schema.reference}
|
||||
label={schema.name}
|
||||
className="px-4 pb-4"
|
||||
>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th className="text-xs !p-2" key="path">
|
||||
Path
|
||||
</Table.th>,
|
||||
<Table.th key="type" className="text-xs !p-2">
|
||||
Type
|
||||
</Table.th>,
|
||||
]}
|
||||
body={schema.fields
|
||||
.sort((a: any, b: any) => a.path - b.path)
|
||||
.map((field) => (
|
||||
<Field key={field.path} field={field} />
|
||||
))}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</SidePanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
export type WarehouseQueryTemplate = {
|
||||
query: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function createWarehouseQueryTemplates(
|
||||
collections: { name: string }[]
|
||||
): WarehouseQueryTemplate[] {
|
||||
return collections.flatMap((collection) => [
|
||||
{
|
||||
query: `select count(*) as event_count from \`${collection.name}\`
|
||||
where timestamp > timestamp_sub(current_timestamp(), interval 1 day)`,
|
||||
name: `${collection.name} - Daily Event Count`,
|
||||
description: `Count events in the last 24 hours from ${collection.name} collection`,
|
||||
},
|
||||
{
|
||||
query: `select id, timestamp, event_message from \`${collection.name}\`
|
||||
where timestamp > timestamp_sub(current_timestamp(), interval 1 day)
|
||||
and event_message like '%YOUR_TEXT_HERE%'
|
||||
order by timestamp desc limit 10`,
|
||||
name: `${collection.name} - Filter by text`,
|
||||
description: `Select last 10 events from ${collection.name} collection`,
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -223,8 +223,6 @@ const ProjectLinks = () => {
|
||||
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
|
||||
const { securityLints, errorLints } = useLints()
|
||||
|
||||
const showWarehouse = useFlag('warehouse')
|
||||
|
||||
const activeRoute = router.pathname.split('/')[3]
|
||||
|
||||
const {
|
||||
@@ -310,14 +308,11 @@ const ProjectLinks = () => {
|
||||
</div>
|
||||
)
|
||||
} else if (route.key === 'logs') {
|
||||
// TODO: Undo this when warehouse flag is removed
|
||||
const label = showWarehouse ? 'Logs & Analytics' : route.label
|
||||
const newRoute = { ...route, label }
|
||||
return (
|
||||
<SideBarNavLink
|
||||
key={`other-routes-${i}`}
|
||||
route={newRoute}
|
||||
active={activeRoute === newRoute.key}
|
||||
route={route}
|
||||
active={activeRoute === route.key}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -60,10 +60,13 @@ const ConfirmResetCodeForm = ({ email }: { email: string }) => {
|
||||
if (user?.factors?.length) {
|
||||
await router.push({
|
||||
pathname: '/forgot-password-mfa',
|
||||
query: { returnTo: '/reset-password' },
|
||||
query: router.query,
|
||||
})
|
||||
} else {
|
||||
await router.push('reset-password')
|
||||
await router.push({
|
||||
pathname: '/reset-password',
|
||||
query: router.query,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { auth } from 'lib/gotrue'
|
||||
import { auth, getReturnToPath } from 'lib/gotrue'
|
||||
import {
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
@@ -67,7 +67,7 @@ const ResetPasswordForm = () => {
|
||||
|
||||
// logout all other sessions after changing password
|
||||
await auth.signOut({ scope: 'others' })
|
||||
await router.push('/organizations')
|
||||
await router.push(getReturnToPath('/organizations'))
|
||||
} else {
|
||||
toast.error(`Failed to save password: ${error.message}`, { id: toastId })
|
||||
if (!WHITELIST_ERRORS.some((e) => error.message.includes(e))) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AuthError } from '@supabase/supabase-js'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { object, string } from 'yup'
|
||||
|
||||
@@ -28,10 +28,22 @@ const SignInForm = () => {
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const captchaRef = useRef<HCaptcha>(null)
|
||||
const [returnTo, setReturnTo] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Only call getReturnToPath after component mounts client-side
|
||||
setReturnTo(getReturnToPath())
|
||||
}, [])
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const { mutate: addLoginEvent } = useAddLoginEvent()
|
||||
|
||||
let forgotPasswordUrl = `/forgot-password`
|
||||
|
||||
if (returnTo && !returnTo.includes('/forgot-password')) {
|
||||
forgotPasswordUrl = `${forgotPasswordUrl}?returnTo=${encodeURIComponent(returnTo)}`
|
||||
}
|
||||
|
||||
const onSignIn = async ({ email, password }: { email: string; password: string }) => {
|
||||
const toastId = toast.loading('Signing in...')
|
||||
|
||||
@@ -68,9 +80,12 @@ const SignInForm = () => {
|
||||
addLoginEvent({})
|
||||
|
||||
await queryClient.resetQueries()
|
||||
const returnTo = getReturnToPath()
|
||||
// since we're already on the /sign-in page, prevent redirect loops
|
||||
router.push(returnTo === '/sign-in' ? '/organizations' : returnTo)
|
||||
let redirectPath = '/organizations'
|
||||
if (returnTo && returnTo !== '/sign-in') {
|
||||
redirectPath = returnTo
|
||||
}
|
||||
router.push(redirectPath)
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId })
|
||||
Sentry.captureMessage('[CRITICAL] Failed to sign in via EP: ' + error.message)
|
||||
@@ -124,7 +139,7 @@ const SignInForm = () => {
|
||||
|
||||
{/* positioned using absolute instead of labelOptional prop so tabbing between inputs works smoothly */}
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
href={forgotPasswordUrl}
|
||||
className="absolute top-0 right-0 text-sm text-foreground-lighter"
|
||||
>
|
||||
Forgot Password?
|
||||
|
||||
@@ -18,7 +18,11 @@ const signInSchema = object({
|
||||
code: string().required('MFA Code is required'),
|
||||
})
|
||||
|
||||
const SignInMfaForm = () => {
|
||||
interface SignInMfaFormProps {
|
||||
context?: 'forgot-password' | 'sign-in'
|
||||
}
|
||||
|
||||
const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
|
||||
const router = useRouter()
|
||||
const signOut = useSignOut()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -38,7 +42,15 @@ const SignInMfaForm = () => {
|
||||
} = useMfaChallengeAndVerifyMutation({
|
||||
onSuccess: async () => {
|
||||
await queryClient.resetQueries()
|
||||
router.push(getReturnToPath())
|
||||
|
||||
if (context === 'forgot-password') {
|
||||
router.push({
|
||||
pathname: '/reset-password',
|
||||
query: router.query,
|
||||
})
|
||||
} else {
|
||||
router.push(getReturnToPath())
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+35
-14
@@ -1,8 +1,10 @@
|
||||
import { ExternalLink, Eye, EyeOff, Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button, Input } from 'ui'
|
||||
import { Button, Input, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
export const DecryptedReadOnlyInput = ({
|
||||
value,
|
||||
@@ -15,10 +17,11 @@ export const DecryptedReadOnlyInput = ({
|
||||
descriptionText: string
|
||||
label: string
|
||||
}) => {
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
const { ref } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
|
||||
const { isLoading: isDecryptedValueLoading, data: decryptedValue } =
|
||||
const { data: decryptedValue, isLoading: isDecryptedValueLoading } =
|
||||
useVaultSecretDecryptedValueQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
@@ -29,22 +32,40 @@ export const DecryptedReadOnlyInput = ({
|
||||
)
|
||||
|
||||
const isLoading = isDecryptedValueLoading && showHidden
|
||||
const renderedValue = secureEntry
|
||||
? isLoading
|
||||
? 'Fetching value from Vault...'
|
||||
: showHidden
|
||||
? decryptedValue
|
||||
: value
|
||||
: value
|
||||
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
readOnly
|
||||
copy
|
||||
disabled
|
||||
value={
|
||||
secureEntry
|
||||
? isLoading
|
||||
? 'Fetching value from Vault...'
|
||||
: showHidden
|
||||
? decryptedValue
|
||||
: value
|
||||
: value
|
||||
label={
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>{label}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href={`/project/${ref}/integrations/vault/secrets?search=${value}`}
|
||||
>
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="text-foreground-lighter hover:text-foreground-light transition"
|
||||
/>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">View parameter in Vault</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
value={renderedValue}
|
||||
type={secureEntry ? (isLoading ? 'text' : showHidden ? 'text' : 'password') : 'text'}
|
||||
descriptionText={descriptionText}
|
||||
layout="horizontal"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RefreshCw, SquareArrowOutUpRight } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
|
||||
import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
|
||||
import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
@@ -9,34 +10,32 @@ import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-s
|
||||
import { FDW } from 'data/fdw/fdws-query'
|
||||
import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Badge, Button, TableCell, TableRow } from 'ui'
|
||||
import { Button, cn, TableCell, TableRow } from 'ui'
|
||||
|
||||
type NamespaceRowProps = {
|
||||
bucketName: string
|
||||
namespace: string
|
||||
schema: string
|
||||
excludedSchemas: string[]
|
||||
tables: (FormattedWrapperTable & { id: number })[]
|
||||
token: string
|
||||
wrapperInstance: FDW
|
||||
wrapperValues: Record<string, string>
|
||||
wrapperMeta: WrapperMeta
|
||||
}
|
||||
|
||||
export const NamespaceRow = ({
|
||||
bucketName,
|
||||
namespace,
|
||||
schema,
|
||||
excludedSchemas,
|
||||
tables,
|
||||
token,
|
||||
wrapperInstance,
|
||||
wrapperValues,
|
||||
wrapperMeta,
|
||||
}: NamespaceRowProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false)
|
||||
|
||||
const targetSchema = tables[0]?.schema_name ?? ''
|
||||
|
||||
const { data: tablesData, isLoading: isLoadingNamespaceTables } = useIcebergNamespaceTablesQuery(
|
||||
{
|
||||
catalogUri: wrapperValues.catalog_uri,
|
||||
@@ -56,7 +55,7 @@ export const NamespaceRow = ({
|
||||
connectionString: project?.connectionString,
|
||||
serverName: wrapperInstance.server_name,
|
||||
sourceSchema: namespace,
|
||||
targetSchema: targetSchema,
|
||||
targetSchema: schema,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,11 +75,11 @@ export const NamespaceRow = ({
|
||||
|
||||
return (
|
||||
<TableRow key={namespace}>
|
||||
<TableCell>
|
||||
<Badge variant="brand">{namespace}</Badge>
|
||||
<TableCell>{namespace}</TableCell>
|
||||
<TableCell className={cn(!schema ? 'text-foreground-lighter' : '')}>
|
||||
{schema ?? 'No schema'}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">{schema && <Badge>{schema}</Badge>}</TableCell>
|
||||
<TableCell className="text-foreground-light text-center">
|
||||
<TableCell>
|
||||
{tablesData ? `${tables.length}/${tablesData.length} connected tables` : ``}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -96,15 +95,15 @@ export const NamespaceRow = ({
|
||||
Sync
|
||||
</ButtonTooltip>
|
||||
{schema ? (
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${project?.ref}/editor?schema=${schema}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button type="default" icon={<SquareArrowOutUpRight />}>
|
||||
Table Editor
|
||||
</Button>
|
||||
</a>
|
||||
<Button asChild type="default" icon={<SquareArrowOutUpRight />}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`${BASE_PATH}/project/${project?.ref}/editor?schema=${schema}`}
|
||||
>
|
||||
Open in Table Editor
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
@@ -114,7 +113,7 @@ export const NamespaceRow = ({
|
||||
content: { text: 'There are no tables connected.' },
|
||||
}}
|
||||
>
|
||||
Table Editor
|
||||
Open in Table Editor
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,8 +121,7 @@ export const NamespaceRow = ({
|
||||
<ImportForeignSchemaDialog
|
||||
bucketName={bucketName}
|
||||
namespace={namespace}
|
||||
excludedSchemas={excludedSchemas}
|
||||
wrapperValues={wrapperValues}
|
||||
wrapperMeta={wrapperMeta}
|
||||
visible={importForeignSchemaShown}
|
||||
onClose={() => setImportForeignSchemaShown(false)}
|
||||
/>
|
||||
|
||||
@@ -127,7 +127,6 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
}
|
||||
})
|
||||
}, [wrapperTables, namespacesData])
|
||||
const excludedSchemas = uniq(namespaces.map((n) => n.schema))
|
||||
|
||||
const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers')
|
||||
|
||||
@@ -196,10 +195,10 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center">Namespace</TableHead>
|
||||
<TableHead className="text-center">Schema</TableHead>
|
||||
<TableHead className="text-center">Tables</TableHead>
|
||||
<TableHead className="text-center">Actions</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Schema</TableHead>
|
||||
<TableHead>Tables</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -209,11 +208,11 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
bucketName={bucket.name}
|
||||
namespace={namespace}
|
||||
schema={schema}
|
||||
excludedSchemas={excludedSchemas}
|
||||
tables={tables as any}
|
||||
token={token!}
|
||||
wrapperInstance={wrapperInstance}
|
||||
wrapperValues={wrapperValues}
|
||||
wrapperMeta={wrapperMeta}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user